diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index a61501d..9272cd6 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -16,6 +16,12 @@ interface LoginBody { password: string; } +const LANGS = ["sq", "en"] as const; +type Lang = (typeof LANGS)[number]; +interface LanguageBody { + language: Lang; +} + export async function authRoutes(app: FastifyInstance, db: Db): Promise { app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => { const { username, password } = req.body ?? {}; @@ -41,7 +47,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { csrf, }); setAuthCookies(reply, token, csrf); - return { id: user.id, username: user.username, role: user.role }; + // `language` is NOT in the JWT (identity/role only) — it's a mutable preference + // read from the DB, so changing it needs no token refresh. + return { id: user.id, username: user.username, role: user.role, language: user.language }; }); app.post("/api/auth/logout", async (_req, reply) => { @@ -49,13 +57,30 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { return { ok: true }; }); - // Who am I — used by the SPA to bootstrap session state on load. + // Who am I — used by the SPA to bootstrap session state on load. Reads the live + // `language` preference from the DB (not the token). app.get( "/api/auth/me", { preHandler: requireRole("admin", "operator", "cashier", "readonly") }, async (req) => { const { sub, username, role } = req.user; - return { id: sub, username, role }; + const row = await db.select().from(users).where(eq(users.id, sub)).get(); + return { id: sub, username, role, language: row?.language ?? "sq" }; + }, + ); + + // Change MY own UI language preference (any signed-in user). Persisted to the + // users row so it's restored on the next login, from any booth. See i18n.md. + app.put<{ Body: LanguageBody }>( + "/api/auth/language", + { preHandler: requireRole("admin", "operator", "cashier", "readonly") }, + async (req, reply) => { + const language = req.body?.language; + if (!language || !LANGS.includes(language)) { + return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` }); + } + await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run(); + return { language }; }, ); } diff --git a/packages/db/drizzle/0003_early_hawkeye.sql b/packages/db/drizzle/0003_early_hawkeye.sql new file mode 100644 index 0000000..425c0a4 --- /dev/null +++ b/packages/db/drizzle/0003_early_hawkeye.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `language` text DEFAULT 'sq' NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..18eafb0 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,813 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "620eba1b-2c7e-4bd4-8b3e-c779a69e87b9", + "prevId": "dbee8e05-0b49-4af7-962c-9aab53b36eb7", + "tables": { + "blocklist": { + "name": "blocklist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_events": { + "name": "device_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "devices": { + "name": "devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "driver_id": { + "name": "driver_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ledger_events": { + "name": "ledger_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ledger_events_index_unique": { + "name": "ledger_events_index_unique", + "columns": [ + "index" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "permit_credentials": { + "name": "permit_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "permit_id": { + "name": "permit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "permit_plates": { + "name": "permit_plates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "permit_id": { + "name": "permit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plate": { + "name": "plate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "permits": { + "name": "permits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "holder_name": { + "name": "holder_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_concurrent": { + "name": "max_concurrent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "valid_from": { + "name": "valid_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "valid_to": { + "name": "valid_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permit_id": { + "name": "permit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entered_at": { + "name": "entered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exited_at": { + "name": "exited_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "last_event_index": { + "name": "last_event_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setup_state": { + "name": "setup_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_config": { + "name": "site_config", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "park_name": { + "name": "park_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operator_name": { + "name": "operator_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nius": { + "name": "nius", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_voucher_default": { + "name": "exit_voucher_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snapshots": { + "name": "snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_at": { + "name": "captured_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tariff_versions": { + "name": "tariff_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tariff_id": { + "name": "tariff_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_from": { + "name": "effective_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tariffs": { + "name": "tariffs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'site'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'sq'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index abe9850..97cd9da 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781713560438, "tag": "0002_panoramic_tiger_shark", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1781774228086, + "tag": "0003_early_hawkeye", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 4a49c3d..3403689 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -22,6 +22,12 @@ export const users = sqliteTable("users", { role: text("role", { enum: ["admin", "operator", "cashier", "readonly"], }).notNull(), + // Preferred UI language for this user (operator-facing). Loaded on login and + // restored from any booth. Albanian is the default. Printed tickets are NOT + // governed by this — they're always Albanian (customer-facing). See i18n.md. + language: text("language", { enum: ["sq", "en"] }) + .notNull() + .default("sq"), createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`),