refactor: moved all data access/storage into repositories

also various refactorings in resource editing (*/[id]/+page.server.ts)
pages
This commit is contained in:
axel 2025-04-17 02:48:54 +02:00
parent 5e3fb972e8
commit d2e064d40f
23 changed files with 558 additions and 212 deletions

View File

@ -1,4 +1,4 @@
import { db } from '$lib/server/db';
import { initRepos, users } from '$lib/server/db';
import { Guard } from '$lib/server/guard';
import { getUserFromSession } from '$lib/server/sessions';
import type { Handle, ServerInit } from '@sveltejs/kit';
@ -7,20 +7,17 @@ import { writeFileSync } from 'fs';
import { nanoid } from 'nanoid';
export const init: ServerInit = async () => {
const anyUser = db.data.users[0];
await initRepos();
if (!anyUser) {
if (!(await users.any())) {
const pass = nanoid();
await db.update(({ users }) => {
users.push({
id: nanoid(),
name: 'admin',
password: bcrypt.hashSync(pass, 10),
admin: true,
groups: [],
devices: [],
});
await users.create({
name: 'admin',
password: bcrypt.hashSync(pass, 10),
admin: true,
groups: [],
devices: [],
});
console.log(`default admin password: ${pass}`);
@ -33,7 +30,7 @@ export const init: ServerInit = async () => {
export const handle: Handle = async ({ event, resolve }) => {
const { cookies, locals } = event;
locals.guard = new Guard(getUserFromSession(cookies.get('session')));
locals.guard = new Guard(await getUserFromSession(cookies.get('session')));
return await resolve(event);
};

View File

@ -0,0 +1,6 @@
import { fail } from '@sveltejs/kit';
import type { ZodError } from 'zod';
export const FORBIDDEN = fail(403, { error: 'You do not have permission to do that.' });
export const SUCCESS = { success: true };
export const PARSE_ERROR = (err: ZodError) => fail(400, { error: err.errors[0].message });

View File

@ -1,42 +1,16 @@
import { JSONFilePreset } from 'lowdb/node';
import type { Device } from './types/device';
import type { Group } from './types/group';
import type { User } from './types/user';
import type { IDeviceRepo } from './repos/deviceRepo';
import type { IGroupRepo } from './repos/groupRepo';
import { LowStorage } from './repos/lowdb/_init';
import type { IUserRepo } from './repos/userRepo';
type AppData = {
users: User[];
groups: Group[];
devices: Device[];
};
export let users: IUserRepo;
export let groups: IGroupRepo;
export let devices: IDeviceRepo;
export const db = await JSONFilePreset<AppData>('./data/db.json', {
users: [],
groups: [],
devices: [],
});
export async function initRepos() {
const repos = await LowStorage.init();
export function getUsersDevices(userId: string): Device[] {
const user = db.data.users.find((u) => u.id === userId)!;
if (user.admin) return db.data.devices;
const userDevices = db.data.devices.filter((d) => user.devices.includes(d.id));
const userGroups = db.data.groups.filter((g) => user.groups.includes(g.id));
const groupDevices = userGroups.flatMap((g) =>
db.data.devices.filter((d) => g.devices.includes(d.id)),
);
// concat and dedupe
return [...new Set([...userDevices, ...groupDevices])];
}
/** @deprecated */
export function assignDefaults<T>(defaults: Partial<T>, obj: any, options = { mutate: false }): T {
for (let k of Object.keys(obj)) {
if (obj[k] === undefined) {
delete obj[k];
}
}
return options.mutate ? Object.assign(defaults, obj) : Object.assign({}, defaults, obj);
users = repos.users;
groups = repos.groups;
devices = repos.devices;
}

View File

@ -0,0 +1,10 @@
import type { New, Updated } from '../types';
import type { Device, DeviceError } from '../types/device';
export interface IDeviceRepo {
getAll(): Promise<Device[]>;
getById(id: string): Promise<Device | undefined>;
create(group: New<Device>): Promise<DeviceError | undefined>;
update(group: Updated<Device>): Promise<DeviceError | undefined>;
delete(groupId: string): Promise<DeviceError | undefined>;
}

View File

@ -0,0 +1,11 @@
import type { New, Updated } from '../types';
import type { Group, GroupError } from '../types/group';
export interface IGroupRepo {
getAll(): Promise<Group[]>;
getById(id: string): Promise<Group | undefined>;
create(group: New<Group>): Promise<GroupError | undefined>;
update(group: Updated<Group>): Promise<GroupError | undefined>;
delete(groupId: string): Promise<GroupError | undefined>;
countUsers(groupId: string): Promise<number>;
}

View File

@ -0,0 +1,29 @@
import { JSONFilePreset } from 'lowdb/node';
import type { Device } from '../../types/device';
import type { Group } from '../../types/group';
import type { User } from '../../types/user';
import { LowUserRepo } from './userRepo';
import { LowGroupRepo } from './groupRepo';
import { LowDeviceRepo } from './deviceRepo';
const db = await JSONFilePreset<LowStorage.LowData>('./data/db.json', {
users: [],
groups: [],
devices: [],
});
export namespace LowStorage {
export type LowData = {
users: User[];
groups: Group[];
devices: Device[];
};
export async function init() {
return {
users: new LowUserRepo(db),
groups: new LowGroupRepo(db),
devices: new LowDeviceRepo(db),
};
}
}

View File

@ -0,0 +1,76 @@
import type { IDeviceRepo } from '../deviceRepo';
import type { Low } from 'lowdb';
import { type New, type Updated } from '../../types';
import { type Device, type DeviceError, DeviceErrors } from '../../types/device';
import { nanoid } from 'nanoid';
import type { LowStorage } from './_init';
export class LowDeviceRepo implements IDeviceRepo {
private readonly db;
constructor(db: Low<LowStorage.LowData>) {
this.db = db;
}
async getAll(): Promise<Device[]> {
return this.db.data.devices;
}
async getById(id: string): Promise<Device | undefined> {
return this.db.data.devices.find((d) => d.id === id);
}
async create(device: New<Device>): Promise<DeviceError | undefined> {
if (this.db.data.devices.find((d) => d.name === device.name)) {
return DeviceErrors.DUPLICATE_NAME;
}
await this.db.update(({ devices }) => {
devices.push({ id: nanoid(), ...device });
});
return undefined;
}
async update(device: Updated<Device>): Promise<DeviceError | undefined> {
if (this.db.data.devices.find((d) => d.name === device.name && d.id !== device.id)) {
return DeviceErrors.DUPLICATE_NAME;
}
let found = false;
await this.db.update(({ devices }) => {
const existingDevice = devices.find((d) => d.id === device.id);
if (!existingDevice) return;
Object.assign(existingDevice, device);
found = true;
});
return found ? undefined : DeviceErrors.NOT_FOUND;
}
async delete(deviceId: string): Promise<DeviceError | undefined> {
let found = false;
this.db.data.devices = this.db.data.devices.filter((d) => {
if (d.id === deviceId) {
found = true;
return false;
}
return true;
});
if (found) {
this.db.data.users.forEach((u) => {
u.devices = u.devices.filter((d) => d !== deviceId);
});
this.db.data.groups.forEach((g) => {
g.devices = g.devices.filter((d) => d !== deviceId);
});
await this.db.write();
}
return found ? undefined : DeviceErrors.NOT_FOUND;
}
}

View File

@ -0,0 +1,86 @@
import type { IGroupRepo } from '../groupRepo';
import type { Low } from 'lowdb';
import { type New, type Updated } from '../../types';
import { type Group, type GroupError, GroupErrors } from '../../types/group';
import { nanoid } from 'nanoid';
import type { LowStorage } from './_init';
export class LowGroupRepo implements IGroupRepo {
private readonly db;
constructor(db: Low<LowStorage.LowData>) {
this.db = db;
}
async getAll(): Promise<Group[]> {
return this.db.data.groups;
}
async getById(id: string): Promise<Group | undefined> {
return this.db.data.groups.find((g) => g.id === id);
}
async create(group: New<Group>): Promise<GroupError | undefined> {
if (this.db.data.groups.find((g) => g.name === group.name)) {
return GroupErrors.DUPLICATE_NAME;
}
for (const deviceId of group.devices) {
if (!this.db.data.devices.some((d) => d.id === deviceId)) {
return GroupErrors.UNKNOWN_DEVICE;
}
}
await this.db.update(({ groups }) => {
groups.push({ id: nanoid(), ...group });
});
return undefined;
}
async update(group: Updated<Group>): Promise<GroupError | undefined> {
if (this.db.data.groups.find((g) => g.name === group.name && g.id !== group.id)) {
return GroupErrors.DUPLICATE_NAME;
}
for (const deviceId of group.devices ?? []) {
if (!this.db.data.devices.some((d) => d.id === deviceId)) {
return GroupErrors.UNKNOWN_DEVICE;
}
}
let found = false;
await this.db.update(({ groups }) => {
const existingGroup = groups.find((g) => g.id === group.id);
if (!existingGroup) return;
Object.assign(existingGroup, group);
found = true;
});
return found ? undefined : GroupErrors.NOT_FOUND;
}
async delete(groupId: string): Promise<GroupError | undefined> {
let found = false;
this.db.data.groups = this.db.data.groups.filter((g) => {
if (g.id === groupId) {
found = true;
return false;
}
return true;
});
if (found) {
this.db.data.users.forEach((u) => (u.groups = u.groups.filter((gid) => gid != groupId)));
await this.db.write();
}
return found ? undefined : GroupErrors.NOT_FOUND;
}
async countUsers(groupId: string): Promise<number> {
return this.db.data.users.filter((u) => u.groups.includes(groupId)).length;
}
}

View File

@ -0,0 +1,113 @@
import type { Low } from 'lowdb';
import { type New, type Updated } from '../../types';
import { UserErrors, type User } from '../../types/user';
import type { IUserRepo } from '../userRepo';
import { nanoid } from 'nanoid';
import type { LowStorage } from './_init';
export class LowUserRepo implements IUserRepo {
private readonly db;
constructor(db: Low<LowStorage.LowData>) {
this.db = db;
}
async any() {
return this.db.data.users[0] != undefined;
}
async getAll() {
return this.db.data.users;
}
async getById(id: string) {
return this.db.data.users.find((u) => u.id == id);
}
async getByName(name: string) {
return this.db.data.users.find((u) => u.name == name);
}
async create(user: New<User>) {
if (!this.db.data.users.find((u) => u.name == user.name)) {
return UserErrors.DUPLICATE_NAME;
}
for (let id of user.devices) {
if (!this.db.data.devices.some((d) => d.id == id)) {
return UserErrors.UNKNOWN_DEVICE;
}
}
for (let id of user.groups) {
if (!this.db.data.groups.some((g) => g.id == id)) {
return UserErrors.UNKNOWN_GROUP;
}
}
await this.db.update(({ users }) => {
users.push({ id: nanoid(), ...user });
});
}
async update(user: Updated<User>) {
if (this.db.data.users.find((u) => u.name == user.name && u.id != user.id)) {
return UserErrors.DUPLICATE_NAME;
}
for (let id of user.devices ?? []) {
if (!this.db.data.devices.some((d) => d.id == id)) {
return UserErrors.UNKNOWN_DEVICE;
}
}
for (let id of user.groups ?? []) {
if (!this.db.data.groups.some((g) => g.id == id)) {
return UserErrors.UNKNOWN_GROUP;
}
}
let found = false;
await this.db.update(({ users }) => {
let existing = users.find((u) => u.id == user.id);
if (!existing) return;
Object.assign(existing, user);
found = true;
});
return found ? undefined : UserErrors.NOT_FOUND;
}
async fetchDevices(userId: string) {
const user = this.db.data.users.find((u) => u.id === userId)!;
if (user.admin) return this.db.data.devices;
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 groupDevices = userGroups.flatMap((g) =>
this.db.data.devices.filter((d) => g.devices.includes(d.id)),
);
// concat and dedupe
return [...new Set([...userDevices, ...groupDevices])];
}
async delete(userId: string) {
let found = false;
this.db.data.users = this.db.data.users.filter((u) => {
if (u.id == userId) {
found = true;
return false;
}
return true;
});
await this.db.write();
return found ? undefined : UserErrors.NOT_FOUND;
}
}

View File

@ -0,0 +1,14 @@
import type { New, Updated } from '../types';
import type { Device } from '../types/device';
import type { User, UserError } from '../types/user';
export interface IUserRepo {
any(): Promise<boolean>;
getAll(): Promise<User[]>;
getById(id: string): Promise<User | undefined>;
getByName(name: string): Promise<User | undefined>;
fetchDevices(userId: string): Promise<Device[]>;
create(user: New<User>): Promise<UserError | undefined>;
update(user: Updated<User>): Promise<UserError | undefined>;
delete(userId: string): Promise<UserError | undefined>;
}

View File

@ -1,3 +1,5 @@
import { AppError } from '.';
export type Device = {
id: string;
name: string;
@ -6,3 +8,16 @@ export type Device = {
port: number;
packets: number;
};
export class DeviceError extends AppError {
constructor(message: string, status: number) {
super(message, status);
this.name = 'DeviceError';
Object.setPrototypeOf(this, DeviceError.prototype);
}
}
export namespace DeviceErrors {
export const NOT_FOUND = new DeviceError('Group not found.', 404);
export const DUPLICATE_NAME = new DeviceError('Device with this name already exists.', 409);
}

View File

@ -1,5 +1,24 @@
import { AppError } from '.';
export type Group = {
id: string;
name: string;
devices: string[];
};
export class GroupError extends AppError {
constructor(message: string, status: number) {
super(message, status);
this.name = 'GroupError';
Object.setPrototypeOf(this, GroupError.prototype);
}
}
export namespace GroupErrors {
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 UNKNOWN_DEVICE = new GroupError(
'One or more of the selected devices do not exist.',
400,
);
}

View File

@ -0,0 +1,38 @@
import { fail } from '@sveltejs/kit';
/** @deprecated unused for now - keeping around just in case but should be removed someday */
export class Result<TOk, TErr extends Error> {
public readonly err;
private readonly res;
constructor(res?: TOk, err?: TErr) {
this.res = res;
this.err = err;
}
public unwrap(): TOk {
if (this.err) {
console.error('Tried to unwrap a Result that contained an error');
throw this.err;
}
return this.res!;
}
}
export class AppError extends Error {
public readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'AppError';
this.status = status;
Object.setPrototypeOf(this, AppError.prototype);
}
public toFail() {
return fail(this.status, { error: this.message });
}
}
export type New<T> = Omit<T, 'id'>;
export type Updated<T> = Partial<T> & { id: string };

View File

@ -1,3 +1,5 @@
import { AppError } from '.';
export type User = {
id: string;
name: string;
@ -14,3 +16,24 @@ export function toPublicUser(user: User): PublicUser {
delete clonedUser.password;
return clonedUser as PublicUser;
}
export class UserError extends AppError {
constructor(message: string, status: number) {
super(message, status);
this.name = 'UserError';
Object.setPrototypeOf(this, UserError.prototype);
}
}
export namespace UserErrors {
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 UNKNOWN_GROUP = new UserError(
'One or more of the selected groups do not exist.',
400,
);
export const UNKNOWN_DEVICE = new UserError(
'One or more of the selected devices do not exist.',
400,
);
}

View File

@ -1,5 +1,5 @@
import { nanoid } from 'nanoid';
import { db } from './db';
import { users } from './db';
type SessionData = {
userId: string;
@ -15,13 +15,13 @@ export function createSession(data: SessionData) {
return token;
}
export function getUserFromSession(sessionId?: string) {
export async function getUserFromSession(sessionId?: string) {
if (!sessionId) return undefined;
const data = sessions.get(sessionId);
if (!data) return undefined;
return db.data.users.find((u) => u.id === data.userId);
return await users.getById(data.userId);
}
export function deleteSession(sessionId?: string) {

View File

@ -1,9 +1,9 @@
import { getUsersDevices } from '$lib/server/db';
import { users } from '$lib/server/db';
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals: { guard } }) => {
const user = guard.requiresAuth().orRedirects().getUser();
return {
devices: getUsersDevices(user.id),
devices: await users.fetchDevices(user.id),
};
};

View File

@ -1,6 +1,6 @@
import { db, getUsersDevices } from '$lib/server/db/index.js';
import { FORBIDDEN, PARSE_ERROR, SUCCESS } from '$lib/server/commonResponses';
import { devices, users } from '$lib/server/db/index.js';
import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import { nanoid } from 'nanoid';
import validator from 'validator';
import { wake } from 'wake_on_lan';
import { z } from 'zod';
@ -8,7 +8,7 @@ import { z } from 'zod';
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
const device = db.data.devices.find((d) => d.id === params.id);
const device = await devices.getById(params.id ?? '');
if (!device && params.id !== 'new') {
redirect(302, '/dash/devices');
@ -21,7 +21,7 @@ export const load: ServerLoad = async ({ locals: { guard }, params }) => {
export const actions = {
update: async ({ request, params, locals: { guard } }) => {
if (guard.requiresAdmin().isFailed()) return fail(403);
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
const form = await request.formData();
@ -55,54 +55,29 @@ export const actions = {
});
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0].message });
return PARSE_ERROR(parsed.error);
}
if (params.id === 'new') {
await db.update(({ devices }) => {
devices.push({ id: nanoid(), ...parsed.data });
});
const err =
params.id === 'new'
? await devices.create(parsed.data)
: await devices.update({ id: params.id ?? '', ...parsed.data });
return { success: true };
}
let found = false;
await db.update(({ devices }) => {
let dev = devices.find((d) => d.id === params.id);
if (!dev) return;
Object.assign(dev, parsed.data);
found = true;
});
return found ? { success: true } : fail(404, { error: 'Device not found.' });
return err ? err.toFail() : SUCCESS;
},
delete: async ({ locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) {
return fail(403);
}
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
db.data.devices = db.data.devices.filter((d) => d.id !== params.id);
db.data.users.forEach((u) => {
u.devices = u.devices.filter((d) => d !== params.id);
});
db.data.groups.forEach((g) => {
g.devices = g.devices.filter((d) => d !== params.id);
});
db.write();
return { success: true };
const err = await devices.delete(params.id ?? '');
return err ? err.toFail() : SUCCESS;
},
wake: async ({ params, locals: { guard } }) => {
guard = guard.requiresAuth();
if (guard.isFailed()) return FORBIDDEN;
if (guard.isFailed()) {
console.log('Failed guard');
return fail(403);
}
const device = getUsersDevices(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) {
return fail(404);
@ -115,5 +90,7 @@ export const actions = {
port: device.port,
num_packets: device.packets,
});
return SUCCESS;
},
} satisfies Actions;

View File

@ -1,16 +1,18 @@
import { db } from '$lib/server/db';
import { groups } from '$lib/server/db';
import { type ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals: { guard } }) => {
guard.requiresAdmin().orRedirects();
const allGroups = await groups.getAll();
let userCounts: { [key: string]: number } = {};
for (let g of allGroups) {
userCounts[g.id] = await groups.countUsers(g.id);
}
return {
groups: db.data.groups,
userCounts: db.data.groups.reduce(
(acc, group) => {
acc[group.id] = db.data.users.filter((u) => u.groups.includes(group.id)).length;
return acc;
},
{} as Record<string, number>,
),
groups: allGroups,
userCounts,
};
};

View File

@ -1,26 +1,26 @@
import { db } from '$lib/server/db';
import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import { nanoid } from 'nanoid';
import { FORBIDDEN, PARSE_ERROR, SUCCESS } from '$lib/server/commonResponses';
import { devices, groups } from '$lib/server/db';
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import { z } from 'zod';
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
const group = db.data.groups.find((g) => g.id === params.id);
const group = await groups.getById(params.id ?? '');
if (!group && params.id !== 'new') {
redirect(302, '/dash/groups');
}
return {
devices: db.data.devices,
devices: await devices.getAll(),
group,
};
};
export const actions = {
update: async ({ request, locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) return fail(403);
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
const form = await request.formData();
@ -29,12 +29,7 @@ export const actions = {
.string({ message: 'Name is required.' })
.min(3, { message: 'Name must be at least 3 characters.' })
.max(24, { message: 'Name must be at most 24 characters.' }),
devices: z.array(
z.string().refine((v) => db.data.devices.find((d) => d.id === v), {
message: 'Invalid device ID.',
}),
),
devices: z.array(z.string()),
});
const parsed = schema.safeParse({
@ -43,41 +38,21 @@ export const actions = {
});
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0].message });
return PARSE_ERROR(parsed.error);
}
if (params.id === 'new') {
await db.update(({ groups }) => {
groups.push({ id: nanoid(), ...parsed.data });
});
const err =
params.id === 'new'
? await groups.create({ ...parsed.data })
: await groups.update({ id: params.id ?? '', ...parsed.data });
return { success: true };
}
let found = false;
await db.update(({ groups }) => {
let group = groups.find((g) => g.id === params.id);
if (!group) return;
Object.assign(group, parsed.data);
found = true;
});
return found ? { success: true } : fail(400, { error: 'Group not found.' });
return err ? err.toFail() : SUCCESS;
},
delete: async ({ locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) {
return fail(403);
}
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
db.data.groups = db.data.groups.filter((g) => g.id !== params.id);
db.data.users.forEach((u) => {
u.groups = u.groups.filter((g) => g !== params.id);
});
db.write();
redirect(302, '/dashboard/groups');
const err = await groups.delete(params.id ?? '');
return err ? err.toFail() : SUCCESS;
},
} satisfies Actions;

View File

@ -1,4 +1,4 @@
import { db } from '$lib/server/db';
import { users } from '$lib/server/db';
import { toPublicUser, type User } from '$lib/server/db/types/user';
import type { ServerLoad } from '@sveltejs/kit';
@ -6,6 +6,6 @@ export const load: ServerLoad = async ({ locals: { guard } }) => {
guard.requiresAdmin().orRedirects();
return {
users: db.data.users.map((u) => toPublicUser(u)),
users: (await users.getAll()).map((u) => toPublicUser(u)),
};
};

View File

@ -1,29 +1,30 @@
import { db } from '$lib/server/db';
import { FORBIDDEN, PARSE_ERROR, SUCCESS } from '$lib/server/commonResponses';
import { devices, groups, users } from '$lib/server/db';
import type { Updated } from '$lib/server/db/types';
import { toPublicUser, type User } from '$lib/server/db/types/user.js';
import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { z } from 'zod';
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
let user = db.data.users.find((u) => u.id === params.id);
let user = await users.getById(params.id ?? '');
if (!user && params.id !== 'new') {
redirect(302, '/dashboard/users');
redirect(302, '/dash/users');
}
return {
user: toPublicUser(user!),
groups: db.data.groups,
devices: db.data.devices,
groups: await groups.getAll(),
devices: await devices.getAll(),
};
};
export const actions = {
update: async ({ request, locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) return fail(403);
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
const form = await request.formData();
@ -35,23 +36,16 @@ export const actions = {
admin: z.boolean(),
password: z
.string()
.optional()
.refine((v) => (params.id === 'new' ? v && v.length > 0 : true), {
.refine((v) => (params.id === 'new' ? v.length > 0 : true), {
// if /new, password must be present
message: 'Password is required at user creation.',
})
.refine((v) => !v || v.length >= 8, {
.refine((v) => (v.length > 0 ? v.length >= 8 : true), {
// if password present, must be at least 8 chars
message: 'Password must be at least 8 characters.',
}),
groups: z.array(
z.string().refine((v) => db.data.groups.find((g) => g.id === v), {
message: 'Invalid group ID.',
}),
),
devices: z.array(
z.string().refine((v) => db.data.devices.find((d) => d.id === v), {
message: 'Invalid device ID.',
}),
),
groups: z.array(z.string()),
devices: z.array(z.string()),
});
const parsed = schema.safeParse({
@ -63,49 +57,35 @@ export const actions = {
});
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0].message });
return PARSE_ERROR(parsed.error);
}
if (params.id === 'new') {
await db.update(({ users }) => {
users.push({
id: nanoid(),
name: parsed.data.name,
admin: parsed.data.admin,
groups: parsed.data.groups,
devices: parsed.data.devices,
password: bcrypt.hashSync(parsed.data.password!, 10),
});
const err = await users.create({
...parsed.data,
password: bcrypt.hashSync(parsed.data.password, 10),
});
return { success: true };
return err ? err.toFail() : SUCCESS;
}
let found = false;
await db.update(({ users }) => {
let user = users.find((u) => u.id === params.id);
if (!user) return;
let updatedUser: Updated<User> = { id: params.id ?? '', ...parsed.data };
user.name = parsed.data.name;
user.admin = parsed.data.admin;
user.groups = parsed.data.groups;
user.devices = parsed.data.devices;
if (parsed.data.password.length > 0) {
updatedUser.password = bcrypt.hashSync(parsed.data.password, 10);
} else {
// avoid saving empty passwpord
delete updatedUser.password;
}
if (parsed.data.password && parsed.data.password.length > 0) {
user.password = bcrypt.hashSync(parsed.data.password, 10);
}
found = true;
});
return found ? { success: true } : fail(404, { error: 'User not found.' });
const err = await users.update(updatedUser);
return err ? err.toFail() : SUCCESS;
},
delete: async ({ locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) {
return fail(403);
}
db.data.users = db.data.users.filter((u) => u.id !== params.id);
db.write();
delete: async ({ locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) return FORBIDDEN;
const err = await users.delete(params.id ?? '');
return err ? err.toFail() : SUCCESS;
},
} satisfies Actions;

View File

@ -17,6 +17,7 @@
});
</script>
<p class="text-white">{form?.error}</p>
<EditPage createOnly={!data.user}>
<InputText
name="name"

View File

@ -1,6 +1,6 @@
import { db } from '$lib/server/db';
import { createSession, getUserFromSession } from '$lib/server/sessions';
import { redirect } from '@sveltejs/kit';
import { users } from '$lib/server/db';
import { createSession } from '$lib/server/sessions';
import { fail, redirect } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
import type { Actions } from './$types';
import { dev } from '$app/environment';
@ -16,17 +16,17 @@ export const actions = {
const password = data.get('password')?.toString();
if (!username || !password) {
return {
return fail(400, {
error: 'MISSING_CREDENTIALS',
};
});
}
const user = db.data.users.find((u) => u.name === username);
const user = await users.getByName(username);
if (!user || !bcrypt.compareSync(password, user.password)) {
return {
return fail(403, {
error: 'INVALID_CREDENTIALS',
};
});
}
cookies.set(