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:
144
src/auth/middleware.ts
Normal file
144
src/auth/middleware.ts
Normal 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
86
src/auth/permissions.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user