style: fixed indent -> everything into 2 spaces

This commit is contained in:
axel 2025-04-17 03:26:15 +02:00
parent d2e064d40f
commit 2981887224
49 changed files with 1255 additions and 1253 deletions

View File

@ -1,5 +1,5 @@
{ {
"useTabs": true, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 100, "printWidth": 100,

View File

@ -2,5 +2,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
* { * {
font-family: "Inter", sans-serif; font-family: 'Inter', sans-serif;
} }

20
src/app.d.ts vendored
View File

@ -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 {};

View File

@ -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>

View File

@ -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);
}; };

View File

@ -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;
} }

View File

@ -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>;
} }

View File

@ -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>;
} }

View File

@ -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),
}; };
} }
} }

View File

@ -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;
} }
} }

View File

@ -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;
} }
} }

View File

@ -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;
} }
} }

View File

@ -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>;
} }

View File

@ -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);
} }

View File

@ -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,
); );
} }

View File

@ -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'>;

View File

@ -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,
); );
} }

View File

@ -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!;
} }
} }

View File

@ -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);
} }

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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`,
}; };
} }

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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()}

View File

@ -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');
}; };

View File

@ -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),
}; };
}; };

View File

@ -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>

View File

@ -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');
}; };

View File

@ -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),
}; };
}; };

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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,
}; };
}; };

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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)),
}; };
}; };

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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}

View File

@ -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');
}; };