style: fixed indent -> everything into 2 spaces
This commit is contained in:
parent
d2e064d40f
commit
2981887224
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"useTabs": true,
|
"useTabs": false,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
|
|||||||
@ -2,5 +2,5 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: "Inter", sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/app.d.ts
vendored
20
src/app.d.ts
vendored
@ -1,19 +1,19 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
|
||||||
import type { Guard } from "$lib/server/guard";
|
import type { Guard } from '$lib/server/guard';
|
||||||
import 'unplugin-icons/types/svelte';
|
import 'unplugin-icons/types/svelte';
|
||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
guard: Guard;
|
guard: Guard;
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
18
src/app.html
18
src/app.html
@ -1,12 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -7,30 +7,30 @@ import { writeFileSync } from 'fs';
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
export const init: ServerInit = async () => {
|
export const init: ServerInit = async () => {
|
||||||
await initRepos();
|
await initRepos();
|
||||||
|
|
||||||
if (!(await users.any())) {
|
if (!(await users.any())) {
|
||||||
const pass = nanoid();
|
const pass = nanoid();
|
||||||
|
|
||||||
await users.create({
|
await users.create({
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
password: bcrypt.hashSync(pass, 10),
|
password: bcrypt.hashSync(pass, 10),
|
||||||
admin: true,
|
admin: true,
|
||||||
groups: [],
|
groups: [],
|
||||||
devices: [],
|
devices: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`default admin password: ${pass}`);
|
console.log(`default admin password: ${pass}`);
|
||||||
console.log("saved to ./default_admin_pass.txt, don't share it and change it asap");
|
console.log("saved to ./default_admin_pass.txt, don't share it and change it asap");
|
||||||
|
|
||||||
writeFileSync('./data/default_admin_pass.txt', pass);
|
writeFileSync('./data/default_admin_pass.txt', pass);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const { cookies, locals } = event;
|
const { cookies, locals } = event;
|
||||||
|
|
||||||
locals.guard = new Guard(await getUserFromSession(cookies.get('session')));
|
locals.guard = new Guard(await getUserFromSession(cookies.get('session')));
|
||||||
|
|
||||||
return await resolve(event);
|
return await resolve(event);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,9 +8,9 @@ export let groups: IGroupRepo;
|
|||||||
export let devices: IDeviceRepo;
|
export let devices: IDeviceRepo;
|
||||||
|
|
||||||
export async function initRepos() {
|
export async function initRepos() {
|
||||||
const repos = await LowStorage.init();
|
const repos = await LowStorage.init();
|
||||||
|
|
||||||
users = repos.users;
|
users = repos.users;
|
||||||
groups = repos.groups;
|
groups = repos.groups;
|
||||||
devices = repos.devices;
|
devices = repos.devices;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import type { New, Updated } from '../types';
|
|||||||
import type { Device, DeviceError } from '../types/device';
|
import type { Device, DeviceError } from '../types/device';
|
||||||
|
|
||||||
export interface IDeviceRepo {
|
export interface IDeviceRepo {
|
||||||
getAll(): Promise<Device[]>;
|
getAll(): Promise<Device[]>;
|
||||||
getById(id: string): Promise<Device | undefined>;
|
getById(id: string): Promise<Device | undefined>;
|
||||||
create(group: New<Device>): Promise<DeviceError | undefined>;
|
create(group: New<Device>): Promise<DeviceError | undefined>;
|
||||||
update(group: Updated<Device>): Promise<DeviceError | undefined>;
|
update(group: Updated<Device>): Promise<DeviceError | undefined>;
|
||||||
delete(groupId: string): Promise<DeviceError | undefined>;
|
delete(groupId: string): Promise<DeviceError | undefined>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import type { New, Updated } from '../types';
|
|||||||
import type { Group, GroupError } from '../types/group';
|
import type { Group, GroupError } from '../types/group';
|
||||||
|
|
||||||
export interface IGroupRepo {
|
export interface IGroupRepo {
|
||||||
getAll(): Promise<Group[]>;
|
getAll(): Promise<Group[]>;
|
||||||
getById(id: string): Promise<Group | undefined>;
|
getById(id: string): Promise<Group | undefined>;
|
||||||
create(group: New<Group>): Promise<GroupError | undefined>;
|
create(group: New<Group>): Promise<GroupError | undefined>;
|
||||||
update(group: Updated<Group>): Promise<GroupError | undefined>;
|
update(group: Updated<Group>): Promise<GroupError | undefined>;
|
||||||
delete(groupId: string): Promise<GroupError | undefined>;
|
delete(groupId: string): Promise<GroupError | undefined>;
|
||||||
countUsers(groupId: string): Promise<number>;
|
countUsers(groupId: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,28 +2,28 @@ import { JSONFilePreset } from 'lowdb/node';
|
|||||||
import type { Device } from '../../types/device';
|
import type { Device } from '../../types/device';
|
||||||
import type { Group } from '../../types/group';
|
import type { Group } from '../../types/group';
|
||||||
import type { User } from '../../types/user';
|
import type { User } from '../../types/user';
|
||||||
import { LowUserRepo } from './userRepo';
|
|
||||||
import { LowGroupRepo } from './groupRepo';
|
|
||||||
import { LowDeviceRepo } from './deviceRepo';
|
import { LowDeviceRepo } from './deviceRepo';
|
||||||
|
import { LowGroupRepo } from './groupRepo';
|
||||||
|
import { LowUserRepo } from './userRepo';
|
||||||
|
|
||||||
const db = await JSONFilePreset<LowStorage.LowData>('./data/db.json', {
|
const db = await JSONFilePreset<LowStorage.LowData>('./data/db.json', {
|
||||||
users: [],
|
users: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
devices: [],
|
devices: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export namespace LowStorage {
|
export namespace LowStorage {
|
||||||
export type LowData = {
|
export type LowData = {
|
||||||
users: User[];
|
users: User[];
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
return {
|
return {
|
||||||
users: new LowUserRepo(db),
|
users: new LowUserRepo(db),
|
||||||
groups: new LowGroupRepo(db),
|
groups: new LowGroupRepo(db),
|
||||||
devices: new LowDeviceRepo(db),
|
devices: new LowDeviceRepo(db),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,76 +1,76 @@
|
|||||||
import type { IDeviceRepo } from '../deviceRepo';
|
|
||||||
import type { Low } from 'lowdb';
|
import type { Low } from 'lowdb';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import { type New, type Updated } from '../../types';
|
import { type New, type Updated } from '../../types';
|
||||||
import { type Device, type DeviceError, DeviceErrors } from '../../types/device';
|
import { type Device, type DeviceError, DeviceErrors } from '../../types/device';
|
||||||
import { nanoid } from 'nanoid';
|
import type { IDeviceRepo } from '../deviceRepo';
|
||||||
import type { LowStorage } from './_init';
|
import type { LowStorage } from './_init';
|
||||||
|
|
||||||
export class LowDeviceRepo implements IDeviceRepo {
|
export class LowDeviceRepo implements IDeviceRepo {
|
||||||
private readonly db;
|
private readonly db;
|
||||||
|
|
||||||
constructor(db: Low<LowStorage.LowData>) {
|
constructor(db: Low<LowStorage.LowData>) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(): Promise<Device[]> {
|
async getAll(): Promise<Device[]> {
|
||||||
return this.db.data.devices;
|
return this.db.data.devices;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string): Promise<Device | undefined> {
|
async getById(id: string): Promise<Device | undefined> {
|
||||||
return this.db.data.devices.find((d) => d.id === id);
|
return this.db.data.devices.find((d) => d.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(device: New<Device>): Promise<DeviceError | undefined> {
|
async create(device: New<Device>): Promise<DeviceError | undefined> {
|
||||||
if (this.db.data.devices.find((d) => d.name === device.name)) {
|
if (this.db.data.devices.find((d) => d.name === device.name)) {
|
||||||
return DeviceErrors.DUPLICATE_NAME;
|
return DeviceErrors.DUPLICATE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.update(({ devices }) => {
|
await this.db.update(({ devices }) => {
|
||||||
devices.push({ id: nanoid(), ...device });
|
devices.push({ id: nanoid(), ...device });
|
||||||
});
|
});
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(device: Updated<Device>): Promise<DeviceError | undefined> {
|
async update(device: Updated<Device>): Promise<DeviceError | undefined> {
|
||||||
if (this.db.data.devices.find((d) => d.name === device.name && d.id !== device.id)) {
|
if (this.db.data.devices.find((d) => d.name === device.name && d.id !== device.id)) {
|
||||||
return DeviceErrors.DUPLICATE_NAME;
|
return DeviceErrors.DUPLICATE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
let found = false;
|
let found = false;
|
||||||
await this.db.update(({ devices }) => {
|
await this.db.update(({ devices }) => {
|
||||||
const existingDevice = devices.find((d) => d.id === device.id);
|
const existingDevice = devices.find((d) => d.id === device.id);
|
||||||
if (!existingDevice) return;
|
if (!existingDevice) return;
|
||||||
|
|
||||||
Object.assign(existingDevice, device);
|
Object.assign(existingDevice, device);
|
||||||
found = true;
|
found = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return found ? undefined : DeviceErrors.NOT_FOUND;
|
return found ? undefined : DeviceErrors.NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(deviceId: string): Promise<DeviceError | undefined> {
|
async delete(deviceId: string): Promise<DeviceError | undefined> {
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
this.db.data.devices = this.db.data.devices.filter((d) => {
|
this.db.data.devices = this.db.data.devices.filter((d) => {
|
||||||
if (d.id === deviceId) {
|
if (d.id === deviceId) {
|
||||||
found = true;
|
found = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (found) {
|
if (found) {
|
||||||
this.db.data.users.forEach((u) => {
|
this.db.data.users.forEach((u) => {
|
||||||
u.devices = u.devices.filter((d) => d !== deviceId);
|
u.devices = u.devices.filter((d) => d !== deviceId);
|
||||||
});
|
});
|
||||||
this.db.data.groups.forEach((g) => {
|
this.db.data.groups.forEach((g) => {
|
||||||
g.devices = g.devices.filter((d) => d !== deviceId);
|
g.devices = g.devices.filter((d) => d !== deviceId);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.db.write();
|
await this.db.write();
|
||||||
}
|
}
|
||||||
|
|
||||||
return found ? undefined : DeviceErrors.NOT_FOUND;
|
return found ? undefined : DeviceErrors.NOT_FOUND;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,86 +1,86 @@
|
|||||||
import type { IGroupRepo } from '../groupRepo';
|
|
||||||
import type { Low } from 'lowdb';
|
import type { Low } from 'lowdb';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import { type New, type Updated } from '../../types';
|
import { type New, type Updated } from '../../types';
|
||||||
import { type Group, type GroupError, GroupErrors } from '../../types/group';
|
import { type Group, type GroupError, GroupErrors } from '../../types/group';
|
||||||
import { nanoid } from 'nanoid';
|
import type { IGroupRepo } from '../groupRepo';
|
||||||
import type { LowStorage } from './_init';
|
import type { LowStorage } from './_init';
|
||||||
|
|
||||||
export class LowGroupRepo implements IGroupRepo {
|
export class LowGroupRepo implements IGroupRepo {
|
||||||
private readonly db;
|
private readonly db;
|
||||||
|
|
||||||
constructor(db: Low<LowStorage.LowData>) {
|
constructor(db: Low<LowStorage.LowData>) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(): Promise<Group[]> {
|
async getAll(): Promise<Group[]> {
|
||||||
return this.db.data.groups;
|
return this.db.data.groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string): Promise<Group | undefined> {
|
async getById(id: string): Promise<Group | undefined> {
|
||||||
return this.db.data.groups.find((g) => g.id === id);
|
return this.db.data.groups.find((g) => g.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(group: New<Group>): Promise<GroupError | undefined> {
|
async create(group: New<Group>): Promise<GroupError | undefined> {
|
||||||
if (this.db.data.groups.find((g) => g.name === group.name)) {
|
if (this.db.data.groups.find((g) => g.name === group.name)) {
|
||||||
return GroupErrors.DUPLICATE_NAME;
|
return GroupErrors.DUPLICATE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const deviceId of group.devices) {
|
for (const deviceId of group.devices) {
|
||||||
if (!this.db.data.devices.some((d) => d.id === deviceId)) {
|
if (!this.db.data.devices.some((d) => d.id === deviceId)) {
|
||||||
return GroupErrors.UNKNOWN_DEVICE;
|
return GroupErrors.UNKNOWN_DEVICE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.update(({ groups }) => {
|
await this.db.update(({ groups }) => {
|
||||||
groups.push({ id: nanoid(), ...group });
|
groups.push({ id: nanoid(), ...group });
|
||||||
});
|
});
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(group: Updated<Group>): Promise<GroupError | undefined> {
|
async update(group: Updated<Group>): Promise<GroupError | undefined> {
|
||||||
if (this.db.data.groups.find((g) => g.name === group.name && g.id !== group.id)) {
|
if (this.db.data.groups.find((g) => g.name === group.name && g.id !== group.id)) {
|
||||||
return GroupErrors.DUPLICATE_NAME;
|
return GroupErrors.DUPLICATE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const deviceId of group.devices ?? []) {
|
for (const deviceId of group.devices ?? []) {
|
||||||
if (!this.db.data.devices.some((d) => d.id === deviceId)) {
|
if (!this.db.data.devices.some((d) => d.id === deviceId)) {
|
||||||
return GroupErrors.UNKNOWN_DEVICE;
|
return GroupErrors.UNKNOWN_DEVICE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let found = false;
|
let found = false;
|
||||||
await this.db.update(({ groups }) => {
|
await this.db.update(({ groups }) => {
|
||||||
const existingGroup = groups.find((g) => g.id === group.id);
|
const existingGroup = groups.find((g) => g.id === group.id);
|
||||||
if (!existingGroup) return;
|
if (!existingGroup) return;
|
||||||
|
|
||||||
Object.assign(existingGroup, group);
|
Object.assign(existingGroup, group);
|
||||||
found = true;
|
found = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return found ? undefined : GroupErrors.NOT_FOUND;
|
return found ? undefined : GroupErrors.NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(groupId: string): Promise<GroupError | undefined> {
|
async delete(groupId: string): Promise<GroupError | undefined> {
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
this.db.data.groups = this.db.data.groups.filter((g) => {
|
this.db.data.groups = this.db.data.groups.filter((g) => {
|
||||||
if (g.id === groupId) {
|
if (g.id === groupId) {
|
||||||
found = true;
|
found = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (found) {
|
if (found) {
|
||||||
this.db.data.users.forEach((u) => (u.groups = u.groups.filter((gid) => gid != groupId)));
|
this.db.data.users.forEach((u) => (u.groups = u.groups.filter((gid) => gid != groupId)));
|
||||||
await this.db.write();
|
await this.db.write();
|
||||||
}
|
}
|
||||||
|
|
||||||
return found ? undefined : GroupErrors.NOT_FOUND;
|
return found ? undefined : GroupErrors.NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
async countUsers(groupId: string): Promise<number> {
|
async countUsers(groupId: string): Promise<number> {
|
||||||
return this.db.data.users.filter((u) => u.groups.includes(groupId)).length;
|
return this.db.data.users.filter((u) => u.groups.includes(groupId)).length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,113 +1,113 @@
|
|||||||
import type { Low } from 'lowdb';
|
import type { Low } from 'lowdb';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import { type New, type Updated } from '../../types';
|
import { type New, type Updated } from '../../types';
|
||||||
import { UserErrors, type User } from '../../types/user';
|
import { UserErrors, type User } from '../../types/user';
|
||||||
import type { IUserRepo } from '../userRepo';
|
import type { IUserRepo } from '../userRepo';
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import type { LowStorage } from './_init';
|
import type { LowStorage } from './_init';
|
||||||
|
|
||||||
export class LowUserRepo implements IUserRepo {
|
export class LowUserRepo implements IUserRepo {
|
||||||
private readonly db;
|
private readonly db;
|
||||||
|
|
||||||
constructor(db: Low<LowStorage.LowData>) {
|
constructor(db: Low<LowStorage.LowData>) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
async any() {
|
async any() {
|
||||||
return this.db.data.users[0] != undefined;
|
return this.db.data.users[0] != undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll() {
|
async getAll() {
|
||||||
return this.db.data.users;
|
return this.db.data.users;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string) {
|
async getById(id: string) {
|
||||||
return this.db.data.users.find((u) => u.id == id);
|
return this.db.data.users.find((u) => u.id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByName(name: string) {
|
async getByName(name: string) {
|
||||||
return this.db.data.users.find((u) => u.name == name);
|
return this.db.data.users.find((u) => u.name == name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(user: New<User>) {
|
async create(user: New<User>) {
|
||||||
if (!this.db.data.users.find((u) => u.name == user.name)) {
|
if (!this.db.data.users.find((u) => u.name == user.name)) {
|
||||||
return UserErrors.DUPLICATE_NAME;
|
return UserErrors.DUPLICATE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let id of user.devices) {
|
for (let id of user.devices) {
|
||||||
if (!this.db.data.devices.some((d) => d.id == id)) {
|
if (!this.db.data.devices.some((d) => d.id == id)) {
|
||||||
return UserErrors.UNKNOWN_DEVICE;
|
return UserErrors.UNKNOWN_DEVICE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let id of user.groups) {
|
for (let id of user.groups) {
|
||||||
if (!this.db.data.groups.some((g) => g.id == id)) {
|
if (!this.db.data.groups.some((g) => g.id == id)) {
|
||||||
return UserErrors.UNKNOWN_GROUP;
|
return UserErrors.UNKNOWN_GROUP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.update(({ users }) => {
|
await this.db.update(({ users }) => {
|
||||||
users.push({ id: nanoid(), ...user });
|
users.push({ id: nanoid(), ...user });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(user: Updated<User>) {
|
async update(user: Updated<User>) {
|
||||||
if (this.db.data.users.find((u) => u.name == user.name && u.id != user.id)) {
|
if (this.db.data.users.find((u) => u.name == user.name && u.id != user.id)) {
|
||||||
return UserErrors.DUPLICATE_NAME;
|
return UserErrors.DUPLICATE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let id of user.devices ?? []) {
|
for (let id of user.devices ?? []) {
|
||||||
if (!this.db.data.devices.some((d) => d.id == id)) {
|
if (!this.db.data.devices.some((d) => d.id == id)) {
|
||||||
return UserErrors.UNKNOWN_DEVICE;
|
return UserErrors.UNKNOWN_DEVICE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let id of user.groups ?? []) {
|
for (let id of user.groups ?? []) {
|
||||||
if (!this.db.data.groups.some((g) => g.id == id)) {
|
if (!this.db.data.groups.some((g) => g.id == id)) {
|
||||||
return UserErrors.UNKNOWN_GROUP;
|
return UserErrors.UNKNOWN_GROUP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let found = false;
|
let found = false;
|
||||||
await this.db.update(({ users }) => {
|
await this.db.update(({ users }) => {
|
||||||
let existing = users.find((u) => u.id == user.id);
|
let existing = users.find((u) => u.id == user.id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
Object.assign(existing, user);
|
Object.assign(existing, user);
|
||||||
found = true;
|
found = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return found ? undefined : UserErrors.NOT_FOUND;
|
return found ? undefined : UserErrors.NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchDevices(userId: string) {
|
async fetchDevices(userId: string) {
|
||||||
const user = this.db.data.users.find((u) => u.id === userId)!;
|
const user = this.db.data.users.find((u) => u.id === userId)!;
|
||||||
|
|
||||||
if (user.admin) return this.db.data.devices;
|
if (user.admin) return this.db.data.devices;
|
||||||
|
|
||||||
const userDevices = this.db.data.devices.filter((d) => user.devices.includes(d.id));
|
const userDevices = this.db.data.devices.filter((d) => user.devices.includes(d.id));
|
||||||
const userGroups = this.db.data.groups.filter((g) => user.groups.includes(g.id));
|
const userGroups = this.db.data.groups.filter((g) => user.groups.includes(g.id));
|
||||||
const groupDevices = userGroups.flatMap((g) =>
|
const groupDevices = userGroups.flatMap((g) =>
|
||||||
this.db.data.devices.filter((d) => g.devices.includes(d.id)),
|
this.db.data.devices.filter((d) => g.devices.includes(d.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// concat and dedupe
|
// concat and dedupe
|
||||||
return [...new Set([...userDevices, ...groupDevices])];
|
return [...new Set([...userDevices, ...groupDevices])];
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(userId: string) {
|
async delete(userId: string) {
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
this.db.data.users = this.db.data.users.filter((u) => {
|
this.db.data.users = this.db.data.users.filter((u) => {
|
||||||
if (u.id == userId) {
|
if (u.id == userId) {
|
||||||
found = true;
|
found = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.db.write();
|
await this.db.write();
|
||||||
|
|
||||||
return found ? undefined : UserErrors.NOT_FOUND;
|
return found ? undefined : UserErrors.NOT_FOUND;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,12 @@ import type { Device } from '../types/device';
|
|||||||
import type { User, UserError } from '../types/user';
|
import type { User, UserError } from '../types/user';
|
||||||
|
|
||||||
export interface IUserRepo {
|
export interface IUserRepo {
|
||||||
any(): Promise<boolean>;
|
any(): Promise<boolean>;
|
||||||
getAll(): Promise<User[]>;
|
getAll(): Promise<User[]>;
|
||||||
getById(id: string): Promise<User | undefined>;
|
getById(id: string): Promise<User | undefined>;
|
||||||
getByName(name: string): Promise<User | undefined>;
|
getByName(name: string): Promise<User | undefined>;
|
||||||
fetchDevices(userId: string): Promise<Device[]>;
|
fetchDevices(userId: string): Promise<Device[]>;
|
||||||
create(user: New<User>): Promise<UserError | undefined>;
|
create(user: New<User>): Promise<UserError | undefined>;
|
||||||
update(user: Updated<User>): Promise<UserError | undefined>;
|
update(user: Updated<User>): Promise<UserError | undefined>;
|
||||||
delete(userId: string): Promise<UserError | undefined>;
|
delete(userId: string): Promise<UserError | undefined>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
import { AppError } from '.';
|
import { AppError } from '.';
|
||||||
|
|
||||||
export type Device = {
|
export type Device = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
mac: string;
|
mac: string;
|
||||||
broadcast: string;
|
broadcast: string;
|
||||||
port: number;
|
port: number;
|
||||||
packets: number;
|
packets: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class DeviceError extends AppError {
|
export class DeviceError extends AppError {
|
||||||
constructor(message: string, status: number) {
|
constructor(message: string, status: number) {
|
||||||
super(message, status);
|
super(message, status);
|
||||||
this.name = 'DeviceError';
|
this.name = 'DeviceError';
|
||||||
Object.setPrototypeOf(this, DeviceError.prototype);
|
Object.setPrototypeOf(this, DeviceError.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace DeviceErrors {
|
export namespace DeviceErrors {
|
||||||
export const NOT_FOUND = new DeviceError('Group not found.', 404);
|
export const NOT_FOUND = new DeviceError('Group not found.', 404);
|
||||||
export const DUPLICATE_NAME = new DeviceError('Device with this name already exists.', 409);
|
export const DUPLICATE_NAME = new DeviceError('Device with this name already exists.', 409);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
import { AppError } from '.';
|
import { AppError } from '.';
|
||||||
|
|
||||||
export type Group = {
|
export type Group = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
devices: string[];
|
devices: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GroupError extends AppError {
|
export class GroupError extends AppError {
|
||||||
constructor(message: string, status: number) {
|
constructor(message: string, status: number) {
|
||||||
super(message, status);
|
super(message, status);
|
||||||
this.name = 'GroupError';
|
this.name = 'GroupError';
|
||||||
Object.setPrototypeOf(this, GroupError.prototype);
|
Object.setPrototypeOf(this, GroupError.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace GroupErrors {
|
export namespace GroupErrors {
|
||||||
export const NOT_FOUND = new GroupError('Group not found.', 404);
|
export const NOT_FOUND = new GroupError('Group not found.', 404);
|
||||||
export const DUPLICATE_NAME = new GroupError('Group with this name already exists.', 409);
|
export const DUPLICATE_NAME = new GroupError('Group with this name already exists.', 409);
|
||||||
export const UNKNOWN_DEVICE = new GroupError(
|
export const UNKNOWN_DEVICE = new GroupError(
|
||||||
'One or more of the selected devices do not exist.',
|
'One or more of the selected devices do not exist.',
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,36 +2,36 @@ import { fail } from '@sveltejs/kit';
|
|||||||
|
|
||||||
/** @deprecated unused for now - keeping around just in case but should be removed someday */
|
/** @deprecated unused for now - keeping around just in case but should be removed someday */
|
||||||
export class Result<TOk, TErr extends Error> {
|
export class Result<TOk, TErr extends Error> {
|
||||||
public readonly err;
|
public readonly err;
|
||||||
private readonly res;
|
private readonly res;
|
||||||
|
|
||||||
constructor(res?: TOk, err?: TErr) {
|
constructor(res?: TOk, err?: TErr) {
|
||||||
this.res = res;
|
this.res = res;
|
||||||
this.err = err;
|
this.err = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
public unwrap(): TOk {
|
public unwrap(): TOk {
|
||||||
if (this.err) {
|
if (this.err) {
|
||||||
console.error('Tried to unwrap a Result that contained an error');
|
console.error('Tried to unwrap a Result that contained an error');
|
||||||
throw this.err;
|
throw this.err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.res!;
|
return this.res!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
public readonly status: number;
|
public readonly status: number;
|
||||||
constructor(message: string, status: number) {
|
constructor(message: string, status: number) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'AppError';
|
this.name = 'AppError';
|
||||||
this.status = status;
|
this.status = status;
|
||||||
Object.setPrototypeOf(this, AppError.prototype);
|
Object.setPrototypeOf(this, AppError.prototype);
|
||||||
}
|
}
|
||||||
|
|
||||||
public toFail() {
|
public toFail() {
|
||||||
return fail(this.status, { error: this.message });
|
return fail(this.status, { error: this.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type New<T> = Omit<T, 'id'>;
|
export type New<T> = Omit<T, 'id'>;
|
||||||
|
|||||||
@ -1,39 +1,39 @@
|
|||||||
import { AppError } from '.';
|
import { AppError } from '.';
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
devices: string[];
|
devices: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicUser = Omit<User, 'password'>;
|
export type PublicUser = Omit<User, 'password'>;
|
||||||
|
|
||||||
export function toPublicUser(user: User): PublicUser {
|
export function toPublicUser(user: User): PublicUser {
|
||||||
const clonedUser = structuredClone(user) as Partial<User>;
|
const clonedUser = structuredClone(user) as Partial<User>;
|
||||||
delete clonedUser.password;
|
delete clonedUser.password;
|
||||||
return clonedUser as PublicUser;
|
return clonedUser as PublicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserError extends AppError {
|
export class UserError extends AppError {
|
||||||
constructor(message: string, status: number) {
|
constructor(message: string, status: number) {
|
||||||
super(message, status);
|
super(message, status);
|
||||||
this.name = 'UserError';
|
this.name = 'UserError';
|
||||||
Object.setPrototypeOf(this, UserError.prototype);
|
Object.setPrototypeOf(this, UserError.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace UserErrors {
|
export namespace UserErrors {
|
||||||
export const NOT_FOUND = new UserError('User not found.', 404);
|
export const NOT_FOUND = new UserError('User not found.', 404);
|
||||||
export const DUPLICATE_NAME = new UserError('User with this username already exists.', 409);
|
export const DUPLICATE_NAME = new UserError('User with this username already exists.', 409);
|
||||||
export const UNKNOWN_GROUP = new UserError(
|
export const UNKNOWN_GROUP = new UserError(
|
||||||
'One or more of the selected groups do not exist.',
|
'One or more of the selected groups do not exist.',
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
export const UNKNOWN_DEVICE = new UserError(
|
export const UNKNOWN_DEVICE = new UserError(
|
||||||
'One or more of the selected devices do not exist.',
|
'One or more of the selected devices do not exist.',
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,50 +2,50 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
import type { User } from './db/types/user';
|
import type { User } from './db/types/user';
|
||||||
|
|
||||||
export class Guard {
|
export class Guard {
|
||||||
private readonly user?: User;
|
private readonly user?: User;
|
||||||
|
|
||||||
private authRequired = false;
|
private authRequired = false;
|
||||||
private adminRequired = false;
|
private adminRequired = false;
|
||||||
|
|
||||||
constructor(user?: User, options?: { authRequired?: boolean; adminRequired?: boolean }) {
|
constructor(user?: User, options?: { authRequired?: boolean; adminRequired?: boolean }) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.authRequired = options?.authRequired ?? false;
|
this.authRequired = options?.authRequired ?? false;
|
||||||
this.adminRequired = options?.adminRequired ?? false;
|
this.adminRequired = options?.adminRequired ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public requiresAuth() {
|
public requiresAuth() {
|
||||||
return new Guard(this.user, { authRequired: true });
|
return new Guard(this.user, { authRequired: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
public requiresAdmin() {
|
public requiresAdmin() {
|
||||||
return new Guard(this.user, { authRequired: true, adminRequired: true });
|
return new Guard(this.user, { authRequired: true, adminRequired: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
public orRedirects() {
|
public orRedirects() {
|
||||||
if (this.authRequired && !this.user) {
|
if (this.authRequired && !this.user) {
|
||||||
redirect(302, '/login');
|
redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.adminRequired && !this.user?.admin) {
|
if (this.adminRequired && !this.user?.admin) {
|
||||||
redirect(302, '/dash');
|
redirect(302, '/dash');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public isFailed() {
|
public isFailed() {
|
||||||
if (this.authRequired && !this.user) {
|
if (this.authRequired && !this.user) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.adminRequired && !this.user?.admin) {
|
if (this.adminRequired && !this.user?.admin) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUser() {
|
public getUser() {
|
||||||
return this.user!;
|
return this.user!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,29 +2,29 @@ import { nanoid } from 'nanoid';
|
|||||||
import { users } from './db';
|
import { users } from './db';
|
||||||
|
|
||||||
type SessionData = {
|
type SessionData = {
|
||||||
userId: string;
|
userId: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessions: Map<string, SessionData> = new Map();
|
const sessions: Map<string, SessionData> = new Map();
|
||||||
|
|
||||||
export function createSession(data: SessionData) {
|
export function createSession(data: SessionData) {
|
||||||
const token = nanoid();
|
const token = nanoid();
|
||||||
sessions.set(token, data);
|
sessions.set(token, data);
|
||||||
setTimeout(() => sessions.delete(token), 1000 * 60 * 60 * 24);
|
setTimeout(() => sessions.delete(token), 1000 * 60 * 60 * 24);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserFromSession(sessionId?: string) {
|
export async function getUserFromSession(sessionId?: string) {
|
||||||
if (!sessionId) return undefined;
|
if (!sessionId) return undefined;
|
||||||
|
|
||||||
const data = sessions.get(sessionId);
|
const data = sessions.get(sessionId);
|
||||||
if (!data) return undefined;
|
if (!data) return undefined;
|
||||||
|
|
||||||
return await users.getById(data.userId);
|
return await users.getById(data.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSession(sessionId?: string) {
|
export function deleteSession(sessionId?: string) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,36 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Checkbox, Label } from 'bits-ui';
|
import { Checkbox, Label } from 'bits-ui';
|
||||||
import IconCheck from '~icons/tabler/check';
|
import IconCheck from '~icons/tabler/check';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
description = null,
|
description = null,
|
||||||
class: extraClasses = '',
|
class: extraClasses = '',
|
||||||
checked = $bindable(),
|
checked = $bindable(),
|
||||||
...others
|
...others
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-sm text-neutral-100">
|
<div class="text-sm text-neutral-100">
|
||||||
<div class="flex items-center gap-4 {description ? 'mb-2' : 'mb-6'}">
|
<div class="flex items-center gap-4 {description ? 'mb-2' : 'mb-6'}">
|
||||||
<Label.Root for={name} class="block uppercase cursor-pointer">{label}</Label.Root>
|
<Label.Root for={name} class="block uppercase cursor-pointer">{label}</Label.Root>
|
||||||
<Checkbox.Root id={name} {name} bind:checked {...others}>
|
<Checkbox.Root id={name} {name} bind:checked {...others}>
|
||||||
{#snippet children({ checked })}
|
{#snippet children({ checked })}
|
||||||
<div
|
<div
|
||||||
class="p-1 border border-neutral-600 rounded-lg bg-neutral-950 hover:bg-neutral-800 transition-all
|
class="p-1 border border-neutral-600 rounded-lg bg-neutral-950 hover:bg-neutral-800 transition-all
|
||||||
duration-300 ease-in-out cursor-pointer"
|
duration-300 ease-in-out cursor-pointer"
|
||||||
>
|
>
|
||||||
<IconCheck
|
<IconCheck
|
||||||
class="transition-all duration-300 ease-in-out {checked ? 'opacity-100' : 'opacity-0'} test"
|
class="transition-all duration-300 ease-in-out {checked
|
||||||
/>
|
? 'opacity-100'
|
||||||
</div>
|
: 'opacity-0'} test"
|
||||||
{/snippet}
|
/>
|
||||||
</Checkbox.Root>
|
</div>
|
||||||
</div>
|
{/snippet}
|
||||||
{#if description}
|
</Checkbox.Root>
|
||||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
</div>
|
||||||
{/if}
|
{#if description}
|
||||||
|
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,139 +1,139 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Combobox, Label } from 'bits-ui';
|
import { Combobox, Label } from 'bits-ui';
|
||||||
import IconCaretDown from '~icons/tabler/caret-down';
|
import IconCaretDown from '~icons/tabler/caret-down';
|
||||||
import IconCaretUp from '~icons/tabler/caret-up';
|
import IconCaretUp from '~icons/tabler/caret-up';
|
||||||
import IconCheck from '~icons/tabler/check';
|
import IconCheck from '~icons/tabler/check';
|
||||||
import IconSelector from '~icons/tabler/selector';
|
import IconSelector from '~icons/tabler/selector';
|
||||||
import { slideFade } from '../transitions/slideFade';
|
import { slideFade } from '../transitions/slideFade';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
description = '',
|
description = '',
|
||||||
data,
|
data,
|
||||||
class: extraClasses = '',
|
class: extraClasses = '',
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
data: {
|
data: {
|
||||||
value: string;
|
value: string;
|
||||||
name: string;
|
name: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}[];
|
}[];
|
||||||
class?: string;
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let searchFiltered = $derived(data.filter((e) => e.name.toLowerCase().startsWith(searchTerm)));
|
let searchFiltered = $derived(data.filter((e) => e.name.toLowerCase().startsWith(searchTerm)));
|
||||||
|
|
||||||
let value = $state(data.filter((d) => d.selected).map((d) => d.value));
|
let value = $state(data.filter((d) => d.selected).map((d) => d.value));
|
||||||
let inputRef: HTMLInputElement | null = $state(null);
|
let inputRef: HTMLInputElement | null = $state(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-sm text-neutral-100">
|
<div class="text-sm text-neutral-100">
|
||||||
<Label.Root
|
<Label.Root
|
||||||
for={name}
|
for={name}
|
||||||
onclick={() => inputRef?.focus()}
|
onclick={() => inputRef?.focus()}
|
||||||
class="block uppercase mb-2 cursor-pointer"
|
class="block uppercase mb-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
|
|
||||||
<Combobox.Root
|
<Combobox.Root
|
||||||
type="multiple"
|
type="multiple"
|
||||||
{name}
|
{name}
|
||||||
bind:value
|
bind:value
|
||||||
onOpenChange={(val) => {
|
onOpenChange={(val) => {
|
||||||
if (!val) searchTerm = '';
|
if (!val) searchTerm = '';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex border border-neutral-600 shadow text-sm rounded-full {description
|
class="flex border border-neutral-600 shadow text-sm rounded-full {description
|
||||||
? 'mb-2'
|
? 'mb-2'
|
||||||
: 'mb-6'}"
|
: 'mb-6'}"
|
||||||
>
|
>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<Combobox.Input
|
<Combobox.Input
|
||||||
bind:ref={inputRef}
|
bind:ref={inputRef}
|
||||||
placeholder="{value.length} selected | Type to search..."
|
placeholder="{value.length} selected | Type to search..."
|
||||||
oninput={(e) => (searchTerm = e.currentTarget.value.toLowerCase())}
|
oninput={(e) => (searchTerm = e.currentTarget.value.toLowerCase())}
|
||||||
class="bg-neutral-950 px-4 py-2 rounded-l-full
|
class="bg-neutral-950 px-4 py-2 rounded-l-full
|
||||||
focus:ring-indigo-500 focus:border-indigo-500 {extraClasses} z-1"
|
focus:ring-indigo-500 focus:border-indigo-500 {extraClasses} z-1"
|
||||||
/>
|
/>
|
||||||
<!-- <Button.Root type="button" class="{searchTerm == '' ? 'hidden' : ''} absolute text-neutral-500 right-3 top-1/2 -translate-y-1/2">
|
<!-- <Button.Root type="button" class="{searchTerm == '' ? 'hidden' : ''} absolute text-neutral-500 right-3 top-1/2 -translate-y-1/2">
|
||||||
<IconX/>
|
<IconX/>
|
||||||
</Button.Root> -->
|
</Button.Root> -->
|
||||||
</div>
|
</div>
|
||||||
<Combobox.Trigger
|
<Combobox.Trigger
|
||||||
class="border-l border-neutral-600 pl-4 pr-3 py-2 rounded-r-full
|
class="border-l border-neutral-600 pl-4 pr-3 py-2 rounded-r-full
|
||||||
cursor-pointer bg-neutral-800 hover:bg-neutral-700 transition-all duration-300 ease-in-out"
|
cursor-pointer bg-neutral-800 hover:bg-neutral-700 transition-all duration-300 ease-in-out"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
Select
|
Select
|
||||||
<IconSelector />
|
<IconSelector />
|
||||||
</div>
|
</div>
|
||||||
</Combobox.Trigger>
|
</Combobox.Trigger>
|
||||||
</div>
|
</div>
|
||||||
<Combobox.Portal>
|
<Combobox.Portal>
|
||||||
<Combobox.Content
|
<Combobox.Content
|
||||||
forceMount
|
forceMount
|
||||||
class="flex flex-col mt-2 min-w-50 sm:min-w-70 max-h-70 shadow-lg
|
class="flex flex-col mt-2 min-w-50 sm:min-w-70 max-h-70 shadow-lg
|
||||||
border border-neutral-600 rounded-2xl bg-neutral-950 p-2 text-sm text-neutral-100"
|
border border-neutral-600 rounded-2xl bg-neutral-950 p-2 text-sm text-neutral-100"
|
||||||
>
|
>
|
||||||
{#snippet child({ wrapperProps, props, open })}
|
{#snippet child({ wrapperProps, props, open })}
|
||||||
{#if open}
|
{#if open}
|
||||||
<div {...wrapperProps}>
|
<div {...wrapperProps}>
|
||||||
<div {...props} transition:slideFade>
|
<div {...props} transition:slideFade>
|
||||||
<Combobox.ScrollUpButton class="flex justify-center relative">
|
<Combobox.ScrollUpButton class="flex justify-center relative">
|
||||||
<div
|
<div
|
||||||
class="absolute top-full inset-x-0 h-5 z-50 pointer-events-none
|
class="absolute top-full inset-x-0 h-5 z-50 pointer-events-none
|
||||||
bg-gradient-to-b from-neutral-950 to-transparent"
|
bg-gradient-to-b from-neutral-950 to-transparent"
|
||||||
></div>
|
></div>
|
||||||
<IconCaretUp />
|
<IconCaretUp />
|
||||||
</Combobox.ScrollUpButton>
|
</Combobox.ScrollUpButton>
|
||||||
<Combobox.Viewport>
|
<Combobox.Viewport>
|
||||||
{#if searchFiltered.length > 0}
|
{#if searchFiltered.length > 0}
|
||||||
{#each searchFiltered as el}
|
{#each searchFiltered as el}
|
||||||
<Combobox.Item
|
<Combobox.Item
|
||||||
label={searchTerm}
|
label={searchTerm}
|
||||||
value={el.value}
|
value={el.value}
|
||||||
class="flex items-center justify-between gap-4 px-4 py-2
|
class="flex items-center justify-between gap-4 px-4 py-2
|
||||||
data-highlighted:bg-neutral-800 rounded-xl
|
data-highlighted:bg-neutral-800 rounded-xl
|
||||||
cursor-pointer transition-all duration-150 ease-in-out"
|
cursor-pointer transition-all duration-150 ease-in-out"
|
||||||
>
|
>
|
||||||
{#snippet children({ selected })}
|
{#snippet children({ selected })}
|
||||||
{el.name}
|
{el.name}
|
||||||
<IconCheck
|
<IconCheck
|
||||||
class="transition-all duration-150 ease-in-out {selected
|
class="transition-all duration-150 ease-in-out {selected
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0'}"
|
: 'opacity-0'}"
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Combobox.Item>
|
</Combobox.Item>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="px-4 py-2 text-neutral-500 cursor-not-allowed">
|
<p class="px-4 py-2 text-neutral-500 cursor-not-allowed">
|
||||||
No results found. Try another term.
|
No results found. Try another term.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</Combobox.Viewport>
|
</Combobox.Viewport>
|
||||||
<Combobox.ScrollDownButton class="flex justify-center relative">
|
<Combobox.ScrollDownButton class="flex justify-center relative">
|
||||||
<IconCaretDown />
|
<IconCaretDown />
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-full inset-x-0 h-5 z-50 pointer-events-none
|
class="absolute bottom-full inset-x-0 h-5 z-50 pointer-events-none
|
||||||
bg-gradient-to-t from-neutral-950 to-transparent"
|
bg-gradient-to-t from-neutral-950 to-transparent"
|
||||||
></div>
|
></div>
|
||||||
</Combobox.ScrollDownButton>
|
</Combobox.ScrollDownButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Combobox.Content>
|
</Combobox.Content>
|
||||||
</Combobox.Portal>
|
</Combobox.Portal>
|
||||||
</Combobox.Root>
|
</Combobox.Root>
|
||||||
|
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,29 +1,29 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Label } from 'bits-ui';
|
import { Label } from 'bits-ui';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
description = null,
|
description = null,
|
||||||
class: extraClasses = '',
|
class: extraClasses = '',
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
...others
|
...others
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-sm text-neutral-100">
|
<div class="text-sm text-neutral-100">
|
||||||
<Label.Root for={name} class="block uppercase mb-2 cursor-pointer">{label}</Label.Root>
|
<Label.Root for={name} class="block uppercase mb-2 cursor-pointer">{label}</Label.Root>
|
||||||
<input
|
<input
|
||||||
id={name}
|
id={name}
|
||||||
{name}
|
{name}
|
||||||
bind:value
|
bind:value
|
||||||
type="text"
|
type="text"
|
||||||
{...others}
|
{...others}
|
||||||
class="border border-neutral-600 shadow bg-neutral-950
|
class="border border-neutral-600 shadow bg-neutral-950
|
||||||
px-4 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500 rounded-full
|
px-4 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500 rounded-full
|
||||||
{extraClasses} {description ? 'mb-2' : 'mb-6'}"
|
{extraClasses} {description ? 'mb-2' : 'mb-6'}"
|
||||||
/>
|
/>
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,52 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from 'bits-ui';
|
import { Button } from 'bits-ui';
|
||||||
import IconDeviceFloppy from '~icons/tabler/device-floppy';
|
import IconDeviceFloppy from '~icons/tabler/device-floppy';
|
||||||
import IconTrash from '~icons/tabler/trash';
|
import IconTrash from '~icons/tabler/trash';
|
||||||
import IconX from '~icons/tabler/x';
|
import IconX from '~icons/tabler/x';
|
||||||
|
|
||||||
let { children, createOnly } = $props();
|
let { children, createOnly } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full sm:px-6 px-3 py-4 max-w-xl mx-auto mb-12">
|
<div class="w-full sm:px-6 px-3 py-4 max-w-xl mx-auto mb-12">
|
||||||
<form method="POST" action="?/update" use:enhance>
|
<form method="POST" action="?/update" use:enhance>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
<div class="flex gap-2 justify-between sm:text-base text-sm">
|
<div class="flex gap-2 justify-between sm:text-base text-sm">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button.Root
|
<Button.Root
|
||||||
type="submit"
|
type="submit"
|
||||||
class="flex items-center gap-2 text-black cursor-pointer
|
class="flex items-center gap-2 text-black cursor-pointer
|
||||||
bg-neutral-100 hover:bg-neutral-400 hover:scale-95 transition-all duration-300 ease-in-out
|
bg-neutral-100 hover:bg-neutral-400 hover:scale-95 transition-all duration-300 ease-in-out
|
||||||
rounded-full py-2 px-4 border border-neutral-100"
|
rounded-full py-2 px-4 border border-neutral-100"
|
||||||
>
|
>
|
||||||
<IconDeviceFloppy />
|
<IconDeviceFloppy />
|
||||||
<p>Save</p>
|
<p>Save</p>
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
|
|
||||||
<Button.Root
|
<Button.Root
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => history.back()}
|
onclick={() => history.back()}
|
||||||
class="flex items-center gap-2 text-white cursor-pointer
|
class="flex items-center gap-2 text-white cursor-pointer
|
||||||
bg-neutral-800 hover:bg-neutral-700 hover:scale-95 transition-all duration-300 ease-in-out
|
bg-neutral-800 hover:bg-neutral-700 hover:scale-95 transition-all duration-300 ease-in-out
|
||||||
rounded-full py-2 px-4 border border-neutral-600"
|
rounded-full py-2 px-4 border border-neutral-600"
|
||||||
>
|
>
|
||||||
<IconX />
|
<IconX />
|
||||||
<p>Cancel</p>
|
<p>Cancel</p>
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !createOnly}
|
{#if !createOnly}
|
||||||
<Button.Root
|
<Button.Root
|
||||||
formaction="?/delete"
|
formaction="?/delete"
|
||||||
class="flex items-center gap-2 text-white cursor-pointer
|
class="flex items-center gap-2 text-white cursor-pointer
|
||||||
bg-red-700 hover:bg-red-500 hover:scale-95 transition-all duration-300 ease-in-out
|
bg-red-700 hover:bg-red-500 hover:scale-95 transition-all duration-300 ease-in-out
|
||||||
rounded-full py-2 px-4 border border-red-500"
|
rounded-full py-2 px-4 border border-red-500"
|
||||||
>
|
>
|
||||||
<IconTrash />
|
<IconTrash />
|
||||||
<p>Delete</p>
|
<p>Delete</p>
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,32 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from 'bits-ui';
|
import { Button } from 'bits-ui';
|
||||||
import IconPlus from '~icons/tabler/plus';
|
import IconPlus from '~icons/tabler/plus';
|
||||||
|
|
||||||
let { createHref = null, msgAdd, children } = $props();
|
let { createHref = null, msgAdd, children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if createHref}
|
{#if createHref}
|
||||||
<Button.Root
|
<Button.Root
|
||||||
href={createHref}
|
href={createHref}
|
||||||
class="fixed flex items-center sm:bottom-12 sm:right-12 bottom-6 right-6
|
class="fixed flex items-center sm:bottom-12 sm:right-12 bottom-6 right-6
|
||||||
bg-neutral-100 rounded-full shadow-xl sm:px-6 sm:py-2 p-4
|
bg-neutral-100 rounded-full shadow-xl sm:px-6 sm:py-2 p-4
|
||||||
transition-all duration-300 ease-in-out
|
transition-all duration-300 ease-in-out
|
||||||
hover:bg-neutral-300 hover:scale-105"
|
hover:bg-neutral-300 hover:scale-105"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:block">{msgAdd}</span>
|
<span class="hidden sm:block">{msgAdd}</span>
|
||||||
<IconPlus class="sm:ml-2" />
|
<IconPlus class="sm:ml-2" />
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="w-full sm:px-6 px-3 py-4 max-w-3xl mx-auto">
|
<div class="w-full sm:px-6 px-3 py-4 max-w-3xl mx-auto">
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="p-4 text-center text-neutral-700">
|
<div class="p-4 text-center text-neutral-700">
|
||||||
<p>Nothing here.</p>
|
<p>Nothing here.</p>
|
||||||
{#if createHref}
|
{#if createHref}
|
||||||
<p>Get started by clicking "{msgAdd}".</p>
|
<p>Get started by clicking "{msgAdd}".</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,38 +8,38 @@ import type { TransitionConfig } from 'svelte/transition';
|
|||||||
// why? made no sense, opacity changed so fast you couldn't see it... oh well
|
// why? made no sense, opacity changed so fast you couldn't see it... oh well
|
||||||
|
|
||||||
export function slideFade(
|
export function slideFade(
|
||||||
node: Element,
|
node: Element,
|
||||||
{ delay = 0, duration = 300, easing = cubicOut, axis = 'y' } = {},
|
{ delay = 0, duration = 300, easing = cubicOut, axis = 'y' } = {},
|
||||||
): TransitionConfig {
|
): TransitionConfig {
|
||||||
const style = getComputedStyle(node);
|
const style = getComputedStyle(node);
|
||||||
|
|
||||||
const opacity = +style.opacity;
|
const opacity = +style.opacity;
|
||||||
|
|
||||||
const primaryProp = axis === 'y' ? 'height' : 'width';
|
const primaryProp = axis === 'y' ? 'height' : 'width';
|
||||||
const primaryVal = parseFloat(style.getPropertyValue(primaryProp));
|
const primaryVal = parseFloat(style.getPropertyValue(primaryProp));
|
||||||
|
|
||||||
const secondaryProps = axis === 'y' ? ['top', 'bottom'] : ['left', 'right'];
|
const secondaryProps = axis === 'y' ? ['top', 'bottom'] : ['left', 'right'];
|
||||||
const padStart = parseFloat(style.getPropertyValue(`padding-${secondaryProps[0]}`));
|
const padStart = parseFloat(style.getPropertyValue(`padding-${secondaryProps[0]}`));
|
||||||
const padEnd = parseFloat(style.getPropertyValue(`padding-${secondaryProps[1]}`));
|
const padEnd = parseFloat(style.getPropertyValue(`padding-${secondaryProps[1]}`));
|
||||||
const marginStart = parseFloat(style.getPropertyValue(`margin-${secondaryProps[0]}`));
|
const marginStart = parseFloat(style.getPropertyValue(`margin-${secondaryProps[0]}`));
|
||||||
const marginEnd = parseFloat(style.getPropertyValue(`margin-${secondaryProps[1]}`));
|
const marginEnd = parseFloat(style.getPropertyValue(`margin-${secondaryProps[1]}`));
|
||||||
const borderWidthStart = parseFloat(style.getPropertyValue(`border-${secondaryProps[0]}-width`));
|
const borderWidthStart = parseFloat(style.getPropertyValue(`border-${secondaryProps[0]}-width`));
|
||||||
const borderWidthEnd = parseFloat(style.getPropertyValue(`border-${secondaryProps[1]}-width`));
|
const borderWidthEnd = parseFloat(style.getPropertyValue(`border-${secondaryProps[1]}-width`));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
delay,
|
delay,
|
||||||
duration,
|
duration,
|
||||||
easing,
|
easing,
|
||||||
css: (t) =>
|
css: (t) =>
|
||||||
'overflow: hidden;' +
|
'overflow: hidden;' +
|
||||||
`opacity: ${t * opacity};` +
|
`opacity: ${t * opacity};` +
|
||||||
`${primaryProp}: ${t * primaryVal}px;` +
|
`${primaryProp}: ${t * primaryVal}px;` +
|
||||||
`padding-${secondaryProps[0]}: ${t * padStart}px;` +
|
`padding-${secondaryProps[0]}: ${t * padStart}px;` +
|
||||||
`padding-${secondaryProps[1]}: ${t * padEnd}px;` +
|
`padding-${secondaryProps[1]}: ${t * padEnd}px;` +
|
||||||
`margin-${secondaryProps[0]}: ${t * marginStart}px;` +
|
`margin-${secondaryProps[0]}: ${t * marginStart}px;` +
|
||||||
`margin-${secondaryProps[1]}: ${t * marginEnd}px;` +
|
`margin-${secondaryProps[1]}: ${t * marginEnd}px;` +
|
||||||
`border-${secondaryProps[0]}-width: ${t * borderWidthStart}px;` +
|
`border-${secondaryProps[0]}-width: ${t * borderWidthStart}px;` +
|
||||||
`border-${secondaryProps[1]}-width: ${t * borderWidthEnd}px;` +
|
`border-${secondaryProps[1]}-width: ${t * borderWidthEnd}px;` +
|
||||||
`min-${primaryProp}: 0`,
|
`min-${primaryProp}: 0`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from 'bits-ui';
|
import { Button } from 'bits-ui';
|
||||||
import IconChevronRight from '~icons/tabler/chevron-right';
|
import IconChevronRight from '~icons/tabler/chevron-right';
|
||||||
import { slideFade } from '../transitions/slideFade';
|
import { slideFade } from '../transitions/slideFade';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
expanded = $bindable(false),
|
expanded = $bindable(false),
|
||||||
renderHidden = false,
|
renderHidden = false,
|
||||||
class: extraClasses = '',
|
class: extraClasses = '',
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={extraClasses}>
|
<div class={extraClasses}>
|
||||||
<Button.Root
|
<Button.Root
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (expanded = !expanded)}
|
onclick={() => (expanded = !expanded)}
|
||||||
class="flex items-center gap-2 cursor-pointer text-neutral-500 uppercase text-sm"
|
class="flex items-center gap-2 cursor-pointer text-neutral-500 uppercase text-sm"
|
||||||
>
|
>
|
||||||
<IconChevronRight class="{expanded ? 'rotate-90' : ''} transition-all duration-300" />
|
<IconChevronRight class="{expanded ? 'rotate-90' : ''} transition-all duration-300" />
|
||||||
{label}
|
{label}
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
|
|
||||||
<!-- a bit hacky, all of that just to keep a smooth slide transition with no side effect -->
|
<!-- a bit hacky, all of that just to keep a smooth slide transition with no side effect -->
|
||||||
<!-- i wish i could just use CSS transitions and display: none -->
|
<!-- i wish i could just use CSS transitions and display: none -->
|
||||||
<!-- but noooo, CSS won't let you animate height: auto because fuck you that's why -->
|
<!-- but noooo, CSS won't let you animate height: auto because fuck you that's why -->
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
<div transition:slideFade class="mt-4">
|
<div transition:slideFade class="mt-4">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{:else if renderHidden}
|
{:else if renderHidden}
|
||||||
<!-- keep a hidden render, useful to send form data even if hidden -->
|
<!-- keep a hidden render, useful to send form data even if hidden -->
|
||||||
<div class="hidden">
|
<div class="hidden">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { children, class: className = '' } = $props();
|
let { children, class: className = '' } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="flex items-center {className}">
|
<nav class="flex items-center {className}">
|
||||||
<ul
|
<ul
|
||||||
role="menubar"
|
role="menubar"
|
||||||
class="flex items-center w-full sm:w-auto justify-between gap-1 p-1 bg-neutral-950
|
class="flex items-center w-full sm:w-auto justify-between gap-1 p-1 bg-neutral-950
|
||||||
border border-neutral-600 rounded-full"
|
border border-neutral-600 rounded-full"
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from 'bits-ui';
|
import { Button } from 'bits-ui';
|
||||||
|
|
||||||
let { Icon = null, children, active, ...others } = $props();
|
let { Icon = null, children, active, ...others } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li role="none">
|
<li role="none">
|
||||||
<Button.Root
|
<Button.Root
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
class="flex items-center cursor-pointer px-4 py-1 rounded-full transition-all duration-300 ease-in-out
|
class="flex items-center cursor-pointer px-4 py-1 rounded-full transition-all duration-300 ease-in-out
|
||||||
{active ? 'bg-neutral-100 text-neutral-900' : 'hover:bg-neutral-600'}"
|
{active ? 'bg-neutral-100 text-neutral-900' : 'hover:bg-neutral-600'}"
|
||||||
{...others}
|
{...others}
|
||||||
>
|
>
|
||||||
{#if Icon}
|
{#if Icon}
|
||||||
<Icon
|
<Icon
|
||||||
class="block overflow-hidden transition-all duration-300 ease-in-out
|
class="block overflow-hidden transition-all duration-300 ease-in-out
|
||||||
{children ? 'w-0 text-neutral-900' : ''}
|
{children ? 'w-0 text-neutral-900' : ''}
|
||||||
{active ? 'w-5 mr-2' : ''}"
|
{active ? 'w-5 mr-2' : ''}"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,37 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from 'bits-ui';
|
import { Button } from 'bits-ui';
|
||||||
import IconEdit from '~icons/tabler/edit';
|
import IconEdit from '~icons/tabler/edit';
|
||||||
import IconPlay from '~icons/tabler/player-play';
|
import IconPlay from '~icons/tabler/player-play';
|
||||||
|
|
||||||
let { title, subtitle, editHref = null, wakePost = null } = $props();
|
let { title, subtitle, editHref = null, wakePost = null } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex gap-10 items-center rounded-full border border-neutral-600 py-3 pl-7 pr-3 bg-neutral-950 shadow"
|
class="flex gap-10 items-center rounded-full border border-neutral-600 py-3 pl-7 pr-3 bg-neutral-950 shadow"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col text-sm">
|
<div class="flex flex-col text-sm">
|
||||||
<p class="text-neutral-300">{title}</p>
|
<p class="text-neutral-300">{title}</p>
|
||||||
<p class="text-neutral-600">{subtitle}</p>
|
<p class="text-neutral-600">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{#if editHref}
|
{#if editHref}
|
||||||
<Button.Root
|
<Button.Root
|
||||||
class="bg-neutral-800 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
class="bg-neutral-800 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
||||||
border border-neutral-600 rounded-full p-4 text-neutral-100"
|
border border-neutral-600 rounded-full p-4 text-neutral-100"
|
||||||
href={editHref}
|
href={editHref}
|
||||||
>
|
>
|
||||||
<IconEdit />
|
<IconEdit />
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if wakePost}
|
{#if wakePost}
|
||||||
<Button.Root
|
<Button.Root
|
||||||
class="bg-emerald-600 hover:bg-emerald-800 transition-all duration-300 ease-in-out
|
class="bg-emerald-600 hover:bg-emerald-800 transition-all duration-300 ease-in-out
|
||||||
border border-emerald-500 rounded-full p-4 text-neutral-100 cursor-pointer"
|
border border-emerald-500 rounded-full p-4 text-neutral-100 cursor-pointer"
|
||||||
>
|
>
|
||||||
<IconPlay />
|
<IconPlay />
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
redirect(302, '/dash');
|
redirect(302, '/dash');
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { toPublicUser } from '$lib/server/db/types/user';
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals: { guard } }) => {
|
export const load: LayoutServerLoad = async ({ locals: { guard } }) => {
|
||||||
const user = guard.requiresAuth().orRedirects().getUser();
|
const user = guard.requiresAuth().orRedirects().getUser();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: toPublicUser(user),
|
user: toPublicUser(user),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,98 +1,98 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||||
import NavBar from '$lib/v2/ui/NavBar.svelte';
|
import { slideFade } from '$lib/v2/transitions/slideFade.js';
|
||||||
import NavBarLink from '$lib/v2/ui/NavBarLink.svelte';
|
import NavBar from '$lib/v2/ui/NavBar.svelte';
|
||||||
import { Button, DropdownMenu } from 'bits-ui';
|
import NavBarLink from '$lib/v2/ui/NavBarLink.svelte';
|
||||||
import IconChevronDown from '~icons/tabler/chevron-down';
|
import { Button, DropdownMenu } from 'bits-ui';
|
||||||
import IconChevronRight from '~icons/tabler/chevron-right';
|
import IconChevronDown from '~icons/tabler/chevron-down';
|
||||||
import IconHome from '~icons/tabler/home';
|
import IconChevronRight from '~icons/tabler/chevron-right';
|
||||||
import IconUsers from '~icons/tabler/users';
|
import IconDeviceDesktopPin from '~icons/tabler/device-desktop-pin';
|
||||||
import IconUsersGroup from '~icons/tabler/users-group';
|
import IconHome from '~icons/tabler/home';
|
||||||
import IconDeviceDesktopPin from '~icons/tabler/device-desktop-pin';
|
import IconLogout from '~icons/tabler/logout';
|
||||||
import IconLogout from '~icons/tabler/logout';
|
import IconUsers from '~icons/tabler/users';
|
||||||
import { slideFade } from '$lib/v2/transitions/slideFade.js';
|
import IconUsersGroup from '~icons/tabler/users-group';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
function isActive(pageName: string) {
|
function isActive(pageName: string) {
|
||||||
return page.route.id?.includes(pageName);
|
return page.route.id?.includes(pageName);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Dashboard - {$pageTitle}</title>
|
<title>Dashboard - {$pageTitle}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="fixed top-0 left-0 right-0 z-50 flex sm:flex-row flex-col
|
class="fixed top-0 left-0 right-0 z-50 flex sm:flex-row flex-col
|
||||||
p-3 sm:p-6 pb-6 gap-3 text-sm text-neutral-100 bg-linear-to-b from-neutral-900 via-neutral-900 to-transparent"
|
p-3 sm:p-6 pb-6 gap-3 text-sm text-neutral-100 bg-linear-to-b from-neutral-900 via-neutral-900 to-transparent"
|
||||||
>
|
>
|
||||||
<NavBar>
|
<NavBar>
|
||||||
<NavBarLink Icon={IconHome} active={isActive('devices')} href="/dash/devices"
|
<NavBarLink Icon={IconHome} active={isActive('devices')} href="/dash/devices"
|
||||||
>Devices</NavBarLink
|
>Devices</NavBarLink
|
||||||
>
|
>
|
||||||
<NavBarLink Icon={IconUsers} active={isActive('users')} href="/dash/users">Users</NavBarLink>
|
<NavBarLink Icon={IconUsers} active={isActive('users')} href="/dash/users">Users</NavBarLink>
|
||||||
<NavBarLink Icon={IconUsersGroup} active={isActive('groups')} href="/dash/groups"
|
<NavBarLink Icon={IconUsersGroup} active={isActive('groups')} href="/dash/groups"
|
||||||
>Groups</NavBarLink
|
>Groups</NavBarLink
|
||||||
>
|
>
|
||||||
</NavBar>
|
</NavBar>
|
||||||
<div class="flex grow">
|
<div class="flex grow">
|
||||||
<div
|
<div
|
||||||
class="flex grow items-center gap-3 text-neutral-100 text-sm sm:text-base font-light shadow"
|
class="flex grow items-center gap-3 text-neutral-100 text-sm sm:text-base font-light shadow"
|
||||||
>
|
>
|
||||||
<IconChevronRight />
|
<IconChevronRight />
|
||||||
<p>{$pageTitle}</p>
|
<p>{$pageTitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger class="rounded-full bg-neutral-950 border border-neutral-600 p-1">
|
<DropdownMenu.Trigger class="rounded-full bg-neutral-950 border border-neutral-600 p-1">
|
||||||
<Button.Root
|
<Button.Root
|
||||||
class="flex items-center p-1 cursor-pointer rounded-full
|
class="flex items-center p-1 cursor-pointer rounded-full
|
||||||
pl-3 pr-2 py-1 gap-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out"
|
pl-3 pr-2 py-1 gap-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out"
|
||||||
>
|
>
|
||||||
<span><span class="hidden md:inline">Signed in as</span> {data.user.name}</span>
|
<span><span class="hidden md:inline">Signed in as</span> {data.user.name}</span>
|
||||||
<IconChevronDown />
|
<IconChevronDown />
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
forceMount
|
forceMount
|
||||||
class="z-1 bg-neutral-950 border border-neutral-600
|
class="z-1 bg-neutral-950 border border-neutral-600
|
||||||
m-3 p-2 flex flex-col gap-2 rounded-2xl shadow-lg"
|
m-3 p-2 flex flex-col gap-2 rounded-2xl shadow-lg"
|
||||||
>
|
>
|
||||||
{#snippet child({ wrapperProps, props, open })}
|
{#snippet child({ wrapperProps, props, open })}
|
||||||
{#if open}
|
{#if open}
|
||||||
<div {...wrapperProps}>
|
<div {...wrapperProps}>
|
||||||
<div {...props} transition:slideFade>
|
<div {...props} transition:slideFade>
|
||||||
<DropdownMenu.Item>
|
<DropdownMenu.Item>
|
||||||
<a
|
<a
|
||||||
href="/sessions"
|
href="/sessions"
|
||||||
class="px-3 py-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
class="px-3 py-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
||||||
rounded-lg flex items-center justify-between gap-4"
|
rounded-lg flex items-center justify-between gap-4"
|
||||||
>
|
>
|
||||||
<p>Sessions</p>
|
<p>Sessions</p>
|
||||||
<IconDeviceDesktopPin />
|
<IconDeviceDesktopPin />
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item>
|
<DropdownMenu.Item>
|
||||||
<a
|
<a
|
||||||
data-sveltekit-preload-data="tap"
|
data-sveltekit-preload-data="tap"
|
||||||
href="/logout"
|
href="/logout"
|
||||||
class="px-3 py-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
class="px-3 py-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
||||||
rounded-lg flex items-center justify-between gap-4"
|
rounded-lg flex items-center justify-between gap-4"
|
||||||
>
|
>
|
||||||
<p>Logout</p>
|
<p>Logout</p>
|
||||||
<IconLogout />
|
<IconLogout />
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-svh bg-neutral-900 overflow-y-scroll sm:pt-20 pt-28">
|
<div class="w-full h-svh bg-neutral-900 overflow-y-scroll sm:pt-20 pt-28">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { redirect, type ServerLoad } from '@sveltejs/kit';
|
import { redirect, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
||||||
guard.requiresAuth().orRedirects();
|
guard.requiresAuth().orRedirects();
|
||||||
redirect(302, '/dash/devices');
|
redirect(302, '/dash/devices');
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import { users } from '$lib/server/db';
|
|||||||
import type { ServerLoad } from '@sveltejs/kit';
|
import type { ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
||||||
const user = guard.requiresAuth().orRedirects().getUser();
|
const user = guard.requiresAuth().orRedirects().getUser();
|
||||||
return {
|
return {
|
||||||
devices: await users.fetchDevices(user.id),
|
devices: await users.fetchDevices(user.id),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
<script>
|
<script>
|
||||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||||
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
||||||
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
$pageTitle = 'Listing all devices';
|
$pageTitle = 'Listing all devices';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ListPage createHref={data.user.admin ? '/dash/devices/new' : null} msgAdd="Add Device">
|
<ListPage createHref={data.user.admin ? '/dash/devices/new' : null} msgAdd="Add Device">
|
||||||
<div class="flex gap-4 flex-wrap justify-center">
|
<div class="flex gap-4 flex-wrap justify-center">
|
||||||
{#each data.devices as device}
|
{#each data.devices as device}
|
||||||
<ResCard
|
<ResCard
|
||||||
title={device.name}
|
title={device.name}
|
||||||
subtitle={device.mac}
|
subtitle={device.mac}
|
||||||
editHref={data.user.admin ? `/dash/devices/${device.id}` : null}
|
editHref={data.user.admin ? `/dash/devices/${device.id}` : null}
|
||||||
wakePost={`/dash/devices/${device.id}/wake`}
|
wakePost={`/dash/devices/${device.id}/wake`}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</ListPage>
|
</ListPage>
|
||||||
|
|||||||
@ -6,91 +6,91 @@ import { wake } from 'wake_on_lan';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
|
|
||||||
const device = await devices.getById(params.id ?? '');
|
const device = await devices.getById(params.id ?? '');
|
||||||
|
|
||||||
if (!device && params.id !== 'new') {
|
if (!device && params.id !== 'new') {
|
||||||
redirect(302, '/dash/devices');
|
redirect(302, '/dash/devices');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
device,
|
device,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
update: async ({ request, params, locals: { guard } }) => {
|
update: async ({ request, params, locals: { guard } }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string({ message: 'Name is required.' })
|
.string({ message: 'Name is required.' })
|
||||||
.min(3, { message: 'Name must be at least 3 characters.' })
|
.min(3, { message: 'Name must be at least 3 characters.' })
|
||||||
.max(24, { message: 'Name must be at most 24 characters.' }),
|
.max(24, { message: 'Name must be at most 24 characters.' }),
|
||||||
mac: z
|
mac: z
|
||||||
.string({ message: 'MAC address is required.' })
|
.string({ message: 'MAC address is required.' })
|
||||||
.refine((v) => validator.isMACAddress(v), { message: 'Invalid MAC address.' }),
|
.refine((v) => validator.isMACAddress(v), { message: 'Invalid MAC address.' }),
|
||||||
broadcast: z
|
broadcast: z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => validator.isIP(v), { message: 'Invalid broadcast IP address.' }),
|
.refine((v) => validator.isIP(v), { message: 'Invalid broadcast IP address.' }),
|
||||||
port: z.coerce
|
port: z.coerce
|
||||||
.number({ message: 'Port is invalid.' })
|
.number({ message: 'Port is invalid.' })
|
||||||
.min(1, { message: 'Port must be at least 1.' })
|
.min(1, { message: 'Port must be at least 1.' })
|
||||||
.max(65535, { message: 'Port must be at most 65535.' }),
|
.max(65535, { message: 'Port must be at most 65535.' }),
|
||||||
packets: z.coerce
|
packets: z.coerce
|
||||||
.number({ message: 'Packets quantity is invalid.' })
|
.number({ message: 'Packets quantity is invalid.' })
|
||||||
.min(1, { message: 'Packets quantity must be at least 1.' })
|
.min(1, { message: 'Packets quantity must be at least 1.' })
|
||||||
.max(50, { message: 'Packets quantity must be at most 50.' }),
|
.max(50, { message: 'Packets quantity must be at most 50.' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = schema.safeParse({
|
const parsed = schema.safeParse({
|
||||||
name: form.get('name'),
|
name: form.get('name'),
|
||||||
mac: form.get('mac'),
|
mac: form.get('mac'),
|
||||||
broadcast: form.get('broadcast'),
|
broadcast: form.get('broadcast'),
|
||||||
port: form.get('port'),
|
port: form.get('port'),
|
||||||
packets: form.get('packets'),
|
packets: form.get('packets'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return PARSE_ERROR(parsed.error);
|
return PARSE_ERROR(parsed.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const err =
|
const err =
|
||||||
params.id === 'new'
|
params.id === 'new'
|
||||||
? await devices.create(parsed.data)
|
? await devices.create(parsed.data)
|
||||||
: await devices.update({ id: params.id ?? '', ...parsed.data });
|
: await devices.update({ id: params.id ?? '', ...parsed.data });
|
||||||
|
|
||||||
return err ? err.toFail() : SUCCESS;
|
return err ? err.toFail() : SUCCESS;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async ({ locals: { guard }, params }) => {
|
delete: async ({ locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
||||||
|
|
||||||
const err = await devices.delete(params.id ?? '');
|
const err = await devices.delete(params.id ?? '');
|
||||||
return err ? err.toFail() : SUCCESS;
|
return err ? err.toFail() : SUCCESS;
|
||||||
},
|
},
|
||||||
|
|
||||||
wake: async ({ params, locals: { guard } }) => {
|
wake: async ({ params, locals: { guard } }) => {
|
||||||
guard = guard.requiresAuth();
|
guard = guard.requiresAuth();
|
||||||
if (guard.isFailed()) return FORBIDDEN;
|
if (guard.isFailed()) return FORBIDDEN;
|
||||||
|
|
||||||
const device = (await users.fetchDevices(guard.getUser().id)).find((d) => d.id === params.id);
|
const device = (await users.fetchDevices(guard.getUser().id)).find((d) => d.id === params.id);
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return fail(404);
|
return fail(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Trying to wake ' + device.name);
|
console.log('Trying to wake ' + device.name);
|
||||||
|
|
||||||
wake(device.mac, {
|
wake(device.mac, {
|
||||||
address: device.broadcast,
|
address: device.broadcast,
|
||||||
port: device.port,
|
port: device.port,
|
||||||
num_packets: device.packets,
|
num_packets: device.packets,
|
||||||
});
|
});
|
||||||
|
|
||||||
return SUCCESS;
|
return SUCCESS;
|
||||||
},
|
},
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@ -1,73 +1,73 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import InputText from '$lib/v2/forms/InputText.svelte';
|
import InputText from '$lib/v2/forms/InputText.svelte';
|
||||||
import { pageTitle } from '$lib/v2/globalStores';
|
import { pageTitle } from '$lib/v2/globalStores';
|
||||||
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
||||||
import Collapsible from '$lib/v2/ui/Collapsible.svelte';
|
import Collapsible from '$lib/v2/ui/Collapsible.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
$pageTitle = data.device ? 'Editing device: ' + data.device.name : 'Adding new Device';
|
$pageTitle = data.device ? 'Editing device: ' + data.device.name : 'Adding new Device';
|
||||||
|
|
||||||
// store the state of data that can be collapsed on the form and bind to it
|
// store the state of data that can be collapsed on the form and bind to it
|
||||||
// as to not lose user edits when the form is collapsed and expanded again
|
// as to not lose user edits when the form is collapsed and expanded again
|
||||||
let collapsibleData = $state({
|
let collapsibleData = $state({
|
||||||
broadcast: data.device?.broadcast || '255.255.255.255',
|
broadcast: data.device?.broadcast || '255.255.255.255',
|
||||||
port: data.device?.port || '9',
|
port: data.device?.port || '9',
|
||||||
packets: data.device?.packets || '3',
|
packets: data.device?.packets || '3',
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success && browser) {
|
if (form?.success && browser) {
|
||||||
history.back();
|
history.back();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditPage createOnly={!data.device}>
|
<EditPage createOnly={!data.device}>
|
||||||
<InputText
|
<InputText
|
||||||
name="name"
|
name="name"
|
||||||
label="Name"
|
label="Name"
|
||||||
description="How the device will appear in the dashboard"
|
description="How the device will appear in the dashboard"
|
||||||
value={data.device?.name}
|
value={data.device?.name}
|
||||||
placeholder="New Device"
|
placeholder="New Device"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
name="mac"
|
name="mac"
|
||||||
label="MAC Address"
|
label="MAC Address"
|
||||||
description="Address used for the Wake-On-Lan packets"
|
description="Address used for the Wake-On-Lan packets"
|
||||||
value={data.device?.mac}
|
value={data.device?.mac}
|
||||||
placeholder="00:00:00:00:00:00"
|
placeholder="00:00:00:00:00:00"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapsible label="Advanced" class="mb-6" renderHidden>
|
<Collapsible label="Advanced" class="mb-6" renderHidden>
|
||||||
<InputText
|
<InputText
|
||||||
name="broadcast"
|
name="broadcast"
|
||||||
label="Broadcast IP"
|
label="Broadcast IP"
|
||||||
bind:value={collapsibleData.broadcast}
|
bind:value={collapsibleData.broadcast}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
name="port"
|
name="port"
|
||||||
label="Broadcast Port"
|
label="Broadcast Port"
|
||||||
bind:value={collapsibleData.port}
|
bind:value={collapsibleData.port}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
name="packets"
|
name="packets"
|
||||||
label="Packets Quantity"
|
label="Packets Quantity"
|
||||||
bind:value={collapsibleData.packets}
|
bind:value={collapsibleData.packets}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</EditPage>
|
</EditPage>
|
||||||
|
|||||||
@ -2,17 +2,17 @@ import { groups } from '$lib/server/db';
|
|||||||
import { type ServerLoad } from '@sveltejs/kit';
|
import { type ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
|
|
||||||
const allGroups = await groups.getAll();
|
const allGroups = await groups.getAll();
|
||||||
let userCounts: { [key: string]: number } = {};
|
let userCounts: { [key: string]: number } = {};
|
||||||
|
|
||||||
for (let g of allGroups) {
|
for (let g of allGroups) {
|
||||||
userCounts[g.id] = await groups.countUsers(g.id);
|
userCounts[g.id] = await groups.countUsers(g.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groups: allGroups,
|
groups: allGroups,
|
||||||
userCounts,
|
userCounts,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
<script>
|
<script>
|
||||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||||
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
||||||
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
$pageTitle = 'Listing all groups';
|
$pageTitle = 'Listing all groups';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ListPage createHref={data.user.admin ? '/dash/groups/new' : null} msgAdd="Add Group">
|
<ListPage createHref={data.user.admin ? '/dash/groups/new' : null} msgAdd="Add Group">
|
||||||
<div class="flex gap-4 flex-wrap justify-center">
|
<div class="flex gap-4 flex-wrap justify-center">
|
||||||
{#each data.groups as group}
|
{#each data.groups as group}
|
||||||
<ResCard
|
<ResCard
|
||||||
title={group.name}
|
title={group.name}
|
||||||
subtitle="{data.userCounts[group.id]} users, {group.devices.length} devices"
|
subtitle="{data.userCounts[group.id]} users, {group.devices.length} devices"
|
||||||
editHref={data.user.admin ? `/dash/groups/${group.id}` : null}
|
editHref={data.user.admin ? `/dash/groups/${group.id}` : null}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</ListPage>
|
</ListPage>
|
||||||
|
|||||||
@ -4,55 +4,55 @@ import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
|
|
||||||
const group = await groups.getById(params.id ?? '');
|
const group = await groups.getById(params.id ?? '');
|
||||||
|
|
||||||
if (!group && params.id !== 'new') {
|
if (!group && params.id !== 'new') {
|
||||||
redirect(302, '/dash/groups');
|
redirect(302, '/dash/groups');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
devices: await devices.getAll(),
|
devices: await devices.getAll(),
|
||||||
group,
|
group,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
update: async ({ request, locals: { guard }, params }) => {
|
update: async ({ request, locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string({ message: 'Name is required.' })
|
.string({ message: 'Name is required.' })
|
||||||
.min(3, { message: 'Name must be at least 3 characters.' })
|
.min(3, { message: 'Name must be at least 3 characters.' })
|
||||||
.max(24, { message: 'Name must be at most 24 characters.' }),
|
.max(24, { message: 'Name must be at most 24 characters.' }),
|
||||||
devices: z.array(z.string()),
|
devices: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = schema.safeParse({
|
const parsed = schema.safeParse({
|
||||||
name: form.get('name'),
|
name: form.get('name'),
|
||||||
devices: form.getAll('devices'),
|
devices: form.getAll('devices'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return PARSE_ERROR(parsed.error);
|
return PARSE_ERROR(parsed.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const err =
|
const err =
|
||||||
params.id === 'new'
|
params.id === 'new'
|
||||||
? await groups.create({ ...parsed.data })
|
? await groups.create({ ...parsed.data })
|
||||||
: await groups.update({ id: params.id ?? '', ...parsed.data });
|
: await groups.update({ id: params.id ?? '', ...parsed.data });
|
||||||
|
|
||||||
return err ? err.toFail() : SUCCESS;
|
return err ? err.toFail() : SUCCESS;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async ({ locals: { guard }, params }) => {
|
delete: async ({ locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
||||||
|
|
||||||
const err = await groups.delete(params.id ?? '');
|
const err = await groups.delete(params.id ?? '');
|
||||||
return err ? err.toFail() : SUCCESS;
|
return err ? err.toFail() : SUCCESS;
|
||||||
},
|
},
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@ -1,41 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import InputCombobox from '$lib/v2/forms/InputCombobox.svelte';
|
import InputCombobox from '$lib/v2/forms/InputCombobox.svelte';
|
||||||
import InputText from '$lib/v2/forms/InputText.svelte';
|
import InputText from '$lib/v2/forms/InputText.svelte';
|
||||||
import { pageTitle } from '$lib/v2/globalStores';
|
import { pageTitle } from '$lib/v2/globalStores';
|
||||||
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
$pageTitle = data.group ? 'Editing group: ' + data.group.name : 'Adding new group';
|
$pageTitle = data.group ? 'Editing group: ' + data.group.name : 'Adding new group';
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success && browser) {
|
if (form?.success && browser) {
|
||||||
history.back();
|
history.back();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EditPage createOnly={!data.group}>
|
<EditPage createOnly={!data.group}>
|
||||||
<InputText
|
<InputText
|
||||||
name="name"
|
name="name"
|
||||||
label="Name"
|
label="Name"
|
||||||
description="How the group will appear in the dashboard"
|
description="How the group will appear in the dashboard"
|
||||||
value={data.group?.name}
|
value={data.group?.name}
|
||||||
placeholder="New Group"
|
placeholder="New Group"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputCombobox
|
<InputCombobox
|
||||||
label="Devices"
|
label="Devices"
|
||||||
description="Devices that users who are part of this group will be able to wake"
|
description="Devices that users who are part of this group will be able to wake"
|
||||||
name="devices"
|
name="devices"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
data={data.devices.map((d) => ({
|
data={data.devices.map((d) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
name: d.name,
|
name: d.name,
|
||||||
selected: data.group?.devices.find((devId) => devId === d.id) ? true : false,
|
selected: data.group?.devices.find((devId) => devId === d.id) ? true : false,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</EditPage>
|
</EditPage>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { users } from '$lib/server/db';
|
import { users } from '$lib/server/db';
|
||||||
import { toPublicUser, type User } from '$lib/server/db/types/user';
|
import { toPublicUser } from '$lib/server/db/types/user';
|
||||||
import type { ServerLoad } from '@sveltejs/kit';
|
import type { ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
export const load: ServerLoad = async ({ locals: { guard } }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users: (await users.getAll()).map((u) => toPublicUser(u)),
|
users: (await users.getAll()).map((u) => toPublicUser(u)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
<script>
|
<script>
|
||||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||||
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
||||||
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
$pageTitle = 'Listing all users';
|
$pageTitle = 'Listing all users';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ListPage createHref={data.user.admin ? '/dash/users/new' : null} msgAdd="Add User">
|
<ListPage createHref={data.user.admin ? '/dash/users/new' : null} msgAdd="Add User">
|
||||||
<div class="flex gap-4 flex-wrap justify-center">
|
<div class="flex gap-4 flex-wrap justify-center">
|
||||||
{#each data.users as user}
|
{#each data.users as user}
|
||||||
<ResCard
|
<ResCard
|
||||||
title={user.name}
|
title={user.name}
|
||||||
subtitle="{user.devices.length} devices, {user.groups.length} groups"
|
subtitle="{user.devices.length} devices, {user.groups.length} groups"
|
||||||
editHref={data.user.admin ? `/dash/users/${user.id}` : null}
|
editHref={data.user.admin ? `/dash/users/${user.id}` : null}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</ListPage>
|
</ListPage>
|
||||||
|
|||||||
@ -7,85 +7,85 @@ import bcrypt from 'bcryptjs';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
|
||||||
guard.requiresAdmin().orRedirects();
|
guard.requiresAdmin().orRedirects();
|
||||||
|
|
||||||
let user = await users.getById(params.id ?? '');
|
let user = await users.getById(params.id ?? '');
|
||||||
|
|
||||||
if (!user && params.id !== 'new') {
|
if (!user && params.id !== 'new') {
|
||||||
redirect(302, '/dash/users');
|
redirect(302, '/dash/users');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: toPublicUser(user!),
|
user: toPublicUser(user!),
|
||||||
groups: await groups.getAll(),
|
groups: await groups.getAll(),
|
||||||
devices: await devices.getAll(),
|
devices: await devices.getAll(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
update: async ({ request, locals: { guard }, params }) => {
|
update: async ({ request, locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string({ message: 'Name is required.' })
|
.string({ message: 'Name is required.' })
|
||||||
.min(3, { message: 'Name must be at least 3 characters.' })
|
.min(3, { message: 'Name must be at least 3 characters.' })
|
||||||
.max(24, { message: 'Name must be at most 24 characters.' }),
|
.max(24, { message: 'Name must be at most 24 characters.' }),
|
||||||
admin: z.boolean(),
|
admin: z.boolean(),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => (params.id === 'new' ? v.length > 0 : true), {
|
.refine((v) => (params.id === 'new' ? v.length > 0 : true), {
|
||||||
// if /new, password must be present
|
// if /new, password must be present
|
||||||
message: 'Password is required at user creation.',
|
message: 'Password is required at user creation.',
|
||||||
})
|
})
|
||||||
.refine((v) => (v.length > 0 ? v.length >= 8 : true), {
|
.refine((v) => (v.length > 0 ? v.length >= 8 : true), {
|
||||||
// if password present, must be at least 8 chars
|
// if password present, must be at least 8 chars
|
||||||
message: 'Password must be at least 8 characters.',
|
message: 'Password must be at least 8 characters.',
|
||||||
}),
|
}),
|
||||||
groups: z.array(z.string()),
|
groups: z.array(z.string()),
|
||||||
devices: z.array(z.string()),
|
devices: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = schema.safeParse({
|
const parsed = schema.safeParse({
|
||||||
name: form.get('name'),
|
name: form.get('name'),
|
||||||
admin: form.get('admin') === 'on',
|
admin: form.get('admin') === 'on',
|
||||||
password: form.get('password'),
|
password: form.get('password'),
|
||||||
groups: form.getAll('groups'),
|
groups: form.getAll('groups'),
|
||||||
devices: form.getAll('devices'),
|
devices: form.getAll('devices'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return PARSE_ERROR(parsed.error);
|
return PARSE_ERROR(parsed.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.id === 'new') {
|
if (params.id === 'new') {
|
||||||
const err = await users.create({
|
const err = await users.create({
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
password: bcrypt.hashSync(parsed.data.password, 10),
|
password: bcrypt.hashSync(parsed.data.password, 10),
|
||||||
});
|
});
|
||||||
|
|
||||||
return err ? err.toFail() : SUCCESS;
|
return err ? err.toFail() : SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
let updatedUser: Updated<User> = { id: params.id ?? '', ...parsed.data };
|
let updatedUser: Updated<User> = { id: params.id ?? '', ...parsed.data };
|
||||||
|
|
||||||
if (parsed.data.password.length > 0) {
|
if (parsed.data.password.length > 0) {
|
||||||
updatedUser.password = bcrypt.hashSync(parsed.data.password, 10);
|
updatedUser.password = bcrypt.hashSync(parsed.data.password, 10);
|
||||||
} else {
|
} else {
|
||||||
// avoid saving empty passwpord
|
// avoid saving empty passwpord
|
||||||
delete updatedUser.password;
|
delete updatedUser.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
const err = await users.update(updatedUser);
|
const err = await users.update(updatedUser);
|
||||||
return err ? err.toFail() : SUCCESS;
|
return err ? err.toFail() : SUCCESS;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async ({ locals: { guard }, params }) => {
|
delete: async ({ locals: { guard }, params }) => {
|
||||||
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
|
||||||
|
|
||||||
const err = await users.delete(params.id ?? '');
|
const err = await users.delete(params.id ?? '');
|
||||||
return err ? err.toFail() : SUCCESS;
|
return err ? err.toFail() : SUCCESS;
|
||||||
},
|
},
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@ -1,72 +1,72 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import InputCheckbox from '$lib/v2/forms/InputCheckbox.svelte';
|
import InputCheckbox from '$lib/v2/forms/InputCheckbox.svelte';
|
||||||
import InputCombobox from '$lib/v2/forms/InputCombobox.svelte';
|
import InputCombobox from '$lib/v2/forms/InputCombobox.svelte';
|
||||||
import InputText from '$lib/v2/forms/InputText.svelte';
|
import InputText from '$lib/v2/forms/InputText.svelte';
|
||||||
import { pageTitle } from '$lib/v2/globalStores';
|
import { pageTitle } from '$lib/v2/globalStores';
|
||||||
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
$pageTitle = data.user ? 'Editing user: ' + data.user.name : 'Adding new user';
|
$pageTitle = data.user ? 'Editing user: ' + data.user.name : 'Adding new user';
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success && browser) {
|
if (form?.success && browser) {
|
||||||
history.back();
|
history.back();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p class="text-white">{form?.error}</p>
|
<p class="text-white">{form?.error}</p>
|
||||||
<EditPage createOnly={!data.user}>
|
<EditPage createOnly={!data.user}>
|
||||||
<InputText
|
<InputText
|
||||||
name="name"
|
name="name"
|
||||||
label="Name"
|
label="Name"
|
||||||
description="How the user will appear in the dashboard"
|
description="How the user will appear in the dashboard"
|
||||||
value={data.user?.name}
|
value={data.user?.name}
|
||||||
placeholder="New User"
|
placeholder="New User"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
name="password"
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
description={data.user
|
description={data.user
|
||||||
? "Leave blank to keep the user's current password"
|
? "Leave blank to keep the user's current password"
|
||||||
: 'The password will be hashed and stored in the database'}
|
: 'The password will be hashed and stored in the database'}
|
||||||
type="password"
|
type="password"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputCheckbox
|
<InputCheckbox
|
||||||
name="admin"
|
name="admin"
|
||||||
label="Administrator"
|
label="Administrator"
|
||||||
description="Can manage and use all devices, groups and users"
|
description="Can manage and use all devices, groups and users"
|
||||||
checked={data.user?.admin ?? false}
|
checked={data.user?.admin ?? false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputCombobox
|
<InputCombobox
|
||||||
label="Devices"
|
label="Devices"
|
||||||
description="Devices that the user will be able to wake"
|
description="Devices that the user will be able to wake"
|
||||||
name="devices"
|
name="devices"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
data={data.devices.map((d) => ({
|
data={data.devices.map((d) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
name: d.name,
|
name: d.name,
|
||||||
selected: data.user?.devices.find((devId) => devId === d.id) ? true : false,
|
selected: data.user?.devices.find((devId) => devId === d.id) ? true : false,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputCombobox
|
<InputCombobox
|
||||||
label="Groups"
|
label="Groups"
|
||||||
description="Groups that the user will be a part of and inherit device access from"
|
description="Groups that the user will be a part of and inherit device access from"
|
||||||
name="groups"
|
name="groups"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
data={data.groups.map((g) => ({
|
data={data.groups.map((g) => ({
|
||||||
value: g.id,
|
value: g.id,
|
||||||
name: g.name,
|
name: g.name,
|
||||||
selected: data.user?.groups.find((groupId) => groupId === g.id) ? true : false,
|
selected: data.user?.groups.find((groupId) => groupId === g.id) ? true : false,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</EditPage>
|
</EditPage>
|
||||||
|
|||||||
@ -1,49 +1,49 @@
|
|||||||
|
import { dev } from '$app/environment';
|
||||||
import { users } from '$lib/server/db';
|
import { users } from '$lib/server/db';
|
||||||
import { createSession } from '$lib/server/sessions';
|
import { createSession } from '$lib/server/sessions';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import { dev } from '$app/environment';
|
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ cookies, request, locals: { guard } }) => {
|
default: async ({ cookies, request, locals: { guard } }) => {
|
||||||
if (!guard.requiresAuth().isFailed()) {
|
if (!guard.requiresAuth().isFailed()) {
|
||||||
redirect(302, '/dash');
|
redirect(302, '/dash');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const username = data.get('username')?.toString();
|
const username = data.get('username')?.toString();
|
||||||
const password = data.get('password')?.toString();
|
const password = data.get('password')?.toString();
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: 'MISSING_CREDENTIALS',
|
error: 'MISSING_CREDENTIALS',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await users.getByName(username);
|
const user = await users.getByName(username);
|
||||||
|
|
||||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||||
return fail(403, {
|
return fail(403, {
|
||||||
error: 'INVALID_CREDENTIALS',
|
error: 'INVALID_CREDENTIALS',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cookies.set(
|
cookies.set(
|
||||||
'session',
|
'session',
|
||||||
createSession({
|
createSession({
|
||||||
userAgent: request.headers.get('user-agent') ?? 'UNKNOWN',
|
userAgent: request.headers.get('user-agent') ?? 'UNKNOWN',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !dev, // safari doesn't allow secure cookies on localhost
|
secure: !dev, // safari doesn't allow secure cookies on localhost
|
||||||
sameSite: true,
|
sameSite: true,
|
||||||
maxAge: 60 * 60 * 24,
|
maxAge: 60 * 60 * 24,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
redirect(302, '/dash');
|
redirect(302, '/dash');
|
||||||
},
|
},
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { form }: PageProps = $props();
|
let { form }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input name="username" type="text">
|
<input name="username" type="text" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<input name="password" type="password">
|
<input name="password" type="password" />
|
||||||
</label>
|
</label>
|
||||||
<button>Log in</button>
|
<button>Log in</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<p>Could not login: {form.error}</p>
|
<p>Could not login: {form.error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { deleteSession } from "$lib/server/sessions";
|
import { deleteSession } from '$lib/server/sessions';
|
||||||
import { redirect, type ServerLoad } from "@sveltejs/kit";
|
import { redirect, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ cookies }) => {
|
export const load: ServerLoad = async ({ cookies }) => {
|
||||||
deleteSession(cookies.get("session"));
|
deleteSession(cookies.get('session'));
|
||||||
cookies.delete("session", { path: "/" });
|
cookies.delete('session', { path: '/' });
|
||||||
redirect(302, "/login");
|
redirect(302, '/login');
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user