feat: auth system, scrip scheduler, UI widgets, and new API routes

- Add session-based authentication (login page, middleware, auth context)
- Add cron-like scrip scheduler for time-based conditions
- Add layout builder, scrip wizard, searchable select components
- Add trend chart widget for dashboards
- Add notifications, attachments, queue-permissions API routes
- Add seed-users script
- Update schema with 10 new migrations (0008-0017)
- Apply redesign: Linear-inspired dark theme, conversation-centric UI
- Gitignore runtime data directory

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-15 20:42:17 +02:00
parent 1d4dc38d06
commit 70f0924d4b
59 changed files with 21795 additions and 321 deletions

144
src/auth/middleware.ts Normal file
View File

@@ -0,0 +1,144 @@
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
import * as jose from 'jose';
import { config } from '../config.ts';
import type { Db } from '../db/index.ts';
import { users, apiTokens } from '../db/schema.ts';
import { eq } from 'drizzle-orm';
export interface AuthUser {
userId: string;
username: string;
role: string;
}
declare module 'hono' {
interface ContextVariableMap {
user: AuthUser;
}
}
const secret = new TextEncoder().encode(config.JWT_SECRET);
export async function createToken(user: { id: string; username: string; role: string }): Promise<string> {
return await new jose.SignJWT({ username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
}
async function verifyJwt(token: string): Promise<AuthUser | null> {
try {
const { payload } = await jose.jwtVerify(token, secret);
return {
userId: payload.sub!,
username: payload.username as string,
role: payload.role as string,
};
} catch {
return null;
}
}
async function verifyApiToken(db: Db, token: string): Promise<AuthUser | null> {
try {
// Find all tokens and verify against hash
const allTokens = await db.query.apiTokens.findMany();
for (const t of allTokens) {
const valid = await Bun.password.verify(token, t.token_hash);
if (valid) {
// Update last_used_at
await db.update(apiTokens)
.set({ last_used_at: new Date() } as any)
.where(eq(apiTokens.id, t.id));
const user = await db.query.users.findFirst({
where: eq(users.id, t.user_id),
});
if (user) {
return {
userId: user.id,
username: user.username,
role: user.role,
};
}
}
}
return null;
} catch {
return null;
}
}
function extractToken(c: Context): string | null {
const auth = c.req.header('Authorization');
if (auth?.startsWith('Bearer ')) {
return auth.slice(7);
}
const cookie = c.req.header('Cookie');
if (cookie) {
const match = cookie.match(/(?:^|;\s*)token=([^;]*)/);
if (match?.[1]) return match[1];
}
return null;
}
export function createAuthMiddleware(db: Db) {
async function verifyToken(token: string): Promise<AuthUser | null> {
if (token.startsWith('tessera_')) {
return await verifyApiToken(db, token);
}
return await verifyJwt(token);
}
async function requireAuth(c: Context, next: Next) {
const token = extractToken(c);
if (!token) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const user = await verifyToken(token);
if (!user) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
c.set('user', user);
await next();
}
async function requireAdmin(c: Context, next: Next) {
const token = extractToken(c);
if (!token) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const user = await verifyToken(token);
if (!user) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
if (user.role !== 'admin') {
throw new HTTPException(403, { message: 'Admin access required' });
}
c.set('user', user);
await next();
}
async function optionalAuth(c: Context, next: Next) {
const token = extractToken(c);
if (token) {
const user = await verifyToken(token);
if (user) {
c.set('user', user);
}
}
await next();
}
return { requireAuth, requireAdmin, optionalAuth };
}
export function getUserId(c: Context): string {
const user = c.get('user');
return user?.userId ?? '00000000-0000-0000-0000-000000000000';
}

86
src/auth/permissions.ts Normal file
View File

@@ -0,0 +1,86 @@
import { HTTPException } from 'hono/http-exception';
import type { Context } from 'hono';
import type { Db } from '../db/index.ts';
import { teamMembers, queuePermissions, userPermissions } from '../db/schema.ts';
import { and, eq, inArray } from 'drizzle-orm';
import type { AuthUser } from './middleware.ts';
export type TicketRight = 'ticket.view' | 'ticket.create' | 'ticket.reply' | 'ticket.comment' | 'ticket.modify' | 'queue.admin';
const RIGHT_HIERARCHY: Record<TicketRight, TicketRight[]> = {
'queue.admin': ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'],
'ticket.modify': ['ticket.view', 'ticket.reply', 'ticket.comment', 'ticket.modify'],
'ticket.reply': ['ticket.view', 'ticket.reply'],
'ticket.comment': ['ticket.view', 'ticket.comment'],
'ticket.create': ['ticket.create'],
'ticket.view': ['ticket.view'],
};
/**
* Check whether a user has a specific right on a queue.
* Admins bypass all permission checks.
* Rights come from two sources: team memberships and per-user grants.
* Higher rights imply lower rights (e.g., queue.admin implies ticket.view).
*/
export async function userHasRight(
db: Db,
user: AuthUser,
queueId: string,
right: TicketRight,
): Promise<boolean> {
// Admins have all rights
if (user.role === 'admin') return true;
const neededRights = RIGHT_HIERARCHY[right] ?? [right];
// Check per-user permissions first (direct grant)
const userPerm = await db.query.userPermissions.findFirst({
where: (table, { and, eq: eqFn, inArray: inArr }) =>
and(
eqFn(table.user_id, user.userId),
eqFn(table.queue_id, queueId),
inArr(table.right_name, neededRights),
),
});
if (userPerm) return true;
// Check team permissions (inherited)
const memberships = await db.query.teamMembers.findMany({
where: eq(teamMembers.user_id, user.userId),
});
const teamIds = memberships.map((m) => m.team_id);
if (teamIds.length === 0) return false;
const teamPerm = await db.query.queuePermissions.findFirst({
where: (table, { and, eq: eqFn, inArray: inArr }) =>
and(
inArr(table.team_id, teamIds),
eqFn(table.queue_id, queueId),
inArr(table.right_name, neededRights),
),
});
return teamPerm !== undefined;
}
/**
* Require a specific right on a queue. Throws 403 if the user lacks the right.
*/
export async function requireRight(
c: Context,
db: Db,
queueId: string,
right: TicketRight,
): Promise<void> {
const user = c.get('user');
if (!user) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const has = await userHasRight(db, user, queueId, right);
if (!has) {
throw new HTTPException(403, { message: `Missing required right: ${right}` });
}
}