refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories

This commit is contained in:
jubnl
2026-09-24 01:46:09 +02:00
parent 4c22d83970
commit 862799062a
11 changed files with 779 additions and 191 deletions
@@ -47,4 +47,47 @@ export class AddonsRepository extends TrekRepository<Addons> {
const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' } });
return rows.map((row) => toRow(row) as AddonRow);
}
// ---------------------------------------------------------------------
// Plan 3i Task 1 (`AdminService` — AD26/AD30/AD33/AD36/AD39, admin's
// addon-shelf listing + config/enabled writes).
// ---------------------------------------------------------------------
/**
* AD26 (`admin.service.ts#listAddons`) — `SELECT * FROM addons ORDER BY
* sort_order, id`, UNFILTERED (unlike {@link listEnabled}'s `WHERE
* enabled = 1`) — admin's own catalog listing, filtered afterward in JS
* for managed-instance hiding.
*/
async listAllOrdered(): Promise<AddonRow[]> {
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as AddonRow);
}
/** AD30/AD39 (`admin.service.ts#updateAddon`'s pre-write read and post-TX re-select, byte-identical text at both sites) — `SELECT * FROM addons WHERE id = ?`. */
async findById(id: string): Promise<AddonRow | null> {
const row = await this.findOne({ id });
return row ? (toRow(row) as AddonRow) : null;
}
/** AD33 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE addons SET enabled = ? WHERE id = ?`. */
async setEnabled(id: string, enabled: boolean): Promise<void> {
await this.nativeUpdate({ id }, { enabled: !!enabled });
}
/**
* AD36 (`admin.service.ts#updateAddon`, inside `uow.transactional`) —
* `UPDATE addons SET config = ? WHERE id = ?`. The LLM-parsing addon's API
* key is already encrypted by `AdminService`
* (`prepareLlmAddonConfigForWrite`) before this call — this repository
* never touches the crypto, only persists whatever JSON blob it is
* handed (3e R6's encrypted-column precedent: the service encrypts, the
* repository passes the ciphertext through untouched). `config` is a
* `p.json()` column — MikroORM serializes the object to the stored TEXT
* column itself, the same JSON text the legacy statement's hand-called
* `JSON.stringify` produced.
*/
async setConfig(id: string, config: AddonConfig): Promise<void> {
await this.nativeUpdate({ id }, { config });
}
}
@@ -1,5 +1,5 @@
import type { DocumentProviders } from '../entities/DocumentProviders.entity';
import { type AssertRowKeys } from './_shared/rows';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/** DSCTRL2's wider catalog row — id/name/description/icon, no `enabled`/`sort_order` (those only gate/order the read, they are never returned to the client). */
@@ -69,4 +69,45 @@ export class DocumentProvidersRepository extends TrekRepository<DocumentProvider
);
return rows.map((r) => ({ id: r.id as string, name: r.name, description: r.description ?? null, icon: r.icon ?? null }));
}
// ---------------------------------------------------------------------
// Plan 3i Task 1 (`AdminService` — AD29/AD32/AD35/AD38, admin's
// addon-shelf listing + `enabled` toggle — R6: this repository is 3h's
// doc-sync task's to own; these four are additive, on top of its three
// read shapes above, for admin's reuse).
// ---------------------------------------------------------------------
/**
* AD29 (`admin.service.ts#listAddons`) — `SELECT id, name, description,
* icon, enabled, sort_order FROM document_providers ORDER BY sort_order,
* id`, UNFILTERED (unlike {@link listEnabledCatalog}'s `WHERE enabled =
* 1`).
*/
async listAllOrdered(): Promise<DocumentProviderRow[]> {
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as DocumentProviderRow);
}
/** AD32 (`admin.service.ts#updateAddon`'s pre-write read) — `SELECT * FROM document_providers WHERE id = ?`. */
async findById(id: string): Promise<DocumentProviderRow | null> {
const row = await this.findOne({ id });
return row ? (toRow(row) as DocumentProviderRow) : null;
}
/**
* AD35 (`admin.service.ts#updateAddon`, inside `uow.transactional`, the
* Documents-off cascade) — `UPDATE document_providers SET enabled = 0`,
* deliberately UNSCOPED (every row, no WHERE). Named `disableAll`, not a
* generic `setEnabled` overload that could be called with an
* accidentally omitted id (R4), same reasoning as
* `PhotoProvidersRepository.disableAll` (AD34).
*/
async disableAll(): Promise<void> {
await this.nativeUpdate({}, { enabled: 0 });
}
/** AD38 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE document_providers SET enabled = ? WHERE id = ?`. */
async setEnabled(id: string, enabled: number): Promise<void> {
await this.nativeUpdate({ id }, { enabled });
}
}
@@ -37,4 +37,48 @@ export class PhotoProviderFieldsRepository extends TrekRepository<PhotoProviderF
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as PhotoProviderFieldRow);
}
/**
* AD28 (`admin.service.ts#listAddons`) — `SELECT provider_id, field_key,
* label, input_type, placeholder, required, secret, settings_key,
* payload_key, sort_order FROM photo_provider_fields ORDER BY sort_order,
* id`. A NARROWER projection than {@link listAllOrdered} — no `id`, no
* `hint` — a genuinely different column set for a different caller, not a
* superset reuse (Task 0's report: parity is column-exact here).
*/
async listAllOrderedForAdminShelf(): Promise<PhotoProviderFieldForAdminRow[]> {
const rows = await this.find(
{},
{
fields: ['provider_id', 'field_key', 'label', 'input_type', 'placeholder', 'required', 'secret', 'settings_key', 'payload_key', 'sort_order'],
orderBy: { sort_order: 'asc', id: 'asc' },
},
);
return rows.map((row) => ({
provider_id: row.provider_id,
field_key: row.field_key,
label: row.label,
input_type: row.input_type,
placeholder: row.placeholder ?? null,
required: row.required ?? null,
secret: row.secret ?? null,
settings_key: row.settings_key ?? null,
payload_key: row.payload_key ?? null,
sort_order: row.sort_order ?? null,
}));
}
}
/** AD28's own row shape — see {@link PhotoProviderFieldsRepository.listAllOrderedForAdminShelf}. */
export interface PhotoProviderFieldForAdminRow {
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder: string | null;
required: number | null;
secret: number | null;
settings_key: string | null;
payload_key: string | null;
sort_order: number | null;
}
@@ -55,4 +55,41 @@ export class PhotoProvidersRepository extends TrekRepository<PhotoProviders> {
const row = await this.findOne({ id }, { fields: ['enabled'] });
return row ? { enabled: row.enabled ?? null } : null;
}
// ---------------------------------------------------------------------
// Plan 3i Task 1 (`AdminService` — AD27/AD31/AD34/AD37/AD40, admin's
// addon-shelf listing + enabled writes).
// ---------------------------------------------------------------------
/**
* AD27 (`admin.service.ts#listAddons`) — `SELECT id, name, description,
* icon, enabled, sort_order FROM photo_providers ORDER BY sort_order,
* id`, UNFILTERED (unlike {@link listEnabled}'s `WHERE enabled = 1`).
*/
async listAllOrdered(): Promise<PhotoProviderRow[]> {
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as PhotoProviderRow);
}
/** AD31/AD40 (`admin.service.ts#updateAddon`'s pre-write read and post-TX re-select, byte-identical text at both sites) — `SELECT * FROM photo_providers WHERE id = ?`. */
async findById(id: string): Promise<PhotoProviderRow | null> {
const row = await this.findOne({ id });
return row ? (toRow(row) as PhotoProviderRow) : null;
}
/**
* AD34 (`admin.service.ts#updateAddon`, inside `uow.transactional`, the
* Journey-off cascade — "Journey off takes its providers with it") —
* `UPDATE photo_providers SET enabled = 0`, deliberately UNSCOPED (every
* row, no WHERE). Named `disableAll`, not a generic `setEnabled` overload
* that could be called with an accidentally omitted id (R4).
*/
async disableAll(): Promise<void> {
await this.nativeUpdate({}, { enabled: 0 });
}
/** AD37 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE photo_providers SET enabled = ? WHERE id = ?`. `enabled` stays the raw stored int (this entity's column is `p.integer()`, not `p.boolean()` — see {@link listEnabled}'s docstring), matching the legacy `data.enabled ? 1 : 0` bind exactly. */
async setEnabled(id: string, enabled: number): Promise<void> {
await this.nativeUpdate({ id }, { enabled });
}
}
@@ -191,6 +191,52 @@ export interface ResetTargetRow {
password_version: number;
}
/** AD1 (`admin.service.ts#listUsers`) — the admin user-list projection. */
export interface AdminUserListRow {
id: number;
username: string;
email: string;
role: string;
avatar: string | null;
created_at: string | null;
updated_at: string | null;
last_login: string | null;
}
/** AD5/AD14 (`admin.service.ts#createUser`/`updateUser`'s post-write re-select), byte-identical text at both sites. */
export interface AdminUserSummaryRow {
id: number;
username: string;
email: string;
role: string;
created_at: string | null;
updated_at: string | null;
}
/** AD16 (`admin.service.ts#resetUserMfa`'s pre-write read). */
export interface AdminUserMfaRow {
id: number;
email: string;
mfa_enabled: number | null;
}
/** AD4 (`admin.service.ts#createUser`'s write) — narrower than {@link NewUserRow}: no `first_seen_version`/`login_count`/oidc/avatar columns named, letting the entity's own defaults (`'0.0.0'`/`0`) fill them, the same values the legacy INSERT's omitted columns fell back to via the schema's `DEFAULT`. */
export interface NewAdminUserRow {
username: string;
email: string;
password_hash: string;
role: string;
}
/** AD11 (`admin.service.ts#updateUser`, inside `uow.transactional`) — the columns the legacy COALESCE-shaped UPDATE can touch; a key the caller omits is left unchanged, the same net effect as binding NULL into `COALESCE(?, column)`. */
export interface AdminEditPatch {
username?: string;
email?: string;
role?: string;
password_hash?: string;
password_version?: number;
}
export class UsersRepository extends TrekRepository<Users> {
/**
* **Ruling (Plan 3b Task 1 fix round, supersedes Plan 3a's I1 "`refresh:
@@ -1329,6 +1375,138 @@ export class UsersRepository extends TrekRepository<Users> {
async clearAirtrailApiKey(id: number): Promise<void> {
await this.nativeUpdate({ id }, { airtrail_api_key: null });
}
// ---------------------------------------------------------------------
// Plan 3i Task 1 (`AdminService` — AD1-AD17, admin's user-CRUD surface).
// Every statement not already covered by an existing method above
// (`findById` = AD6, `getRole` = AD9, `countAdmins` = AD10, `disableMfa`
// = AD17, `findIdAndEmail` = AD15 — all reused as-is, no new method).
// ---------------------------------------------------------------------
/**
* AD1 (`listUsers`) — `SELECT id, username, email, role, avatar,
* created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest,
* 0) = 0 ORDER BY created_at DESC`. `is_guest: 0` is exact parity for the
* COALESCE guard (same reasoning as `countNonGuest` above — the column is
* `NOT NULL DEFAULT 0`).
*/
async listForAdmin(): Promise<AdminUserListRow[]> {
const rows = await this.find(
{ is_guest: 0 },
{
fields: ['id', 'username', 'email', 'role', 'avatar', 'created_at', 'updated_at', 'last_login'],
orderBy: { created_at: 'desc' },
},
);
return rows.map((row) => ({
id: row.id,
username: row.username,
email: row.email,
role: row.role,
avatar: row.avatar ?? null,
created_at: row.created_at ?? null,
updated_at: row.updated_at ?? null,
last_login: row.last_login ?? null,
}));
}
/**
* AD2 (`createUser`) — `SELECT id FROM users WHERE username = ? AND
* COALESCE(is_guest, 0) = 0`. Case-**sensitive** (plain equality, no
* `LOWER()`) — distinct from `findIdByUsernameCIAny`'s folded comparison,
* which is a genuinely different legacy statement.
*/
async findIdByUsernameExact(username: string): Promise<number | null> {
const row = await this.findOne({ username, is_guest: 0 }, { fields: ['id'] });
return row?.id ?? null;
}
/** AD3 (`createUser`) — `SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, same reasoning as {@link findIdByUsernameExact}. */
async findIdByEmailExact(email: string): Promise<number | null> {
const row = await this.findOne({ email, is_guest: 0 }, { fields: ['id'] });
return row?.id ?? null;
}
/**
* AD4 (`createUser`'s write) — `INSERT INTO users (username, email,
* password_hash, role) VALUES (?, ?, ?, ?)`. Only these four columns are
* named, matching the legacy statement's own column list exactly; every
* other column (`first_seen_version`, `login_count`, …) is left to the
* entity's own default the same way the legacy INSERT left them to the
* schema's `DEFAULT` — not reused from {@link insertUser} (AU10/O14),
* whose `NewUserRow` requires `first_seen_version` as a caller-supplied
* value, a column this statement never named at all. Returns the
* generated id only (no re-select) — AD5 is a separate, distinct
* statement handled by {@link findAdminSummary}.
*/
async insertAdminCreatedUser(row: NewAdminUserRow): Promise<number> {
return await this.insert({
username: row.username,
email: row.email,
password_hash: row.password_hash,
role: row.role,
});
}
/**
* AD5/AD14 (`createUser`'s post-insert re-select, `updateUser`'s
* post-update re-select) — `SELECT id, username, email, role, created_at,
* updated_at FROM users WHERE id = ?`, byte-identical text at both sites,
* one method (D4).
*/
async findAdminSummary(id: number): Promise<AdminUserSummaryRow | null> {
const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'created_at', 'updated_at'] });
return row
? { id: row.id, username: row.username, email: row.email, role: row.role, created_at: row.created_at ?? null, updated_at: row.updated_at ?? null }
: null;
}
/**
* AD7 (`updateUser`) — `SELECT id FROM users WHERE username = ? AND id !=
* ? AND COALESCE(is_guest, 0) = 0`. Case-**sensitive**, excludes the
* user's own row — distinct from `findIdByUsernameCI`'s `LOWER()`-folded,
* otherwise identically-shaped statement (UP5's own-username check).
*/
async findIdByUsernameExactExcluding(username: string, excludeId: number): Promise<number | null> {
const row = await this.findOne({ username, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] });
return row?.id ?? null;
}
/** AD8 (`updateUser`) — `SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, excludes self, same reasoning as {@link findIdByUsernameExactExcluding}. */
async findIdByEmailExactExcluding(email: string, excludeId: number): Promise<number | null> {
const row = await this.findOne({ email, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] });
return row?.id ?? null;
}
/**
* AD11 (`updateUser`, inside `uow.transactional` alongside AD12/AD13) —
* `UPDATE users SET username=COALESCE(?,username), email=COALESCE(?,email),
* role=COALESCE(?,role), password_hash=COALESCE(?,password_hash),
* password_version=COALESCE(?,password_version), updated_at=CURRENT_TIMESTAMP
* WHERE id=?`. The legacy binds a NULL parameter for "no change" — a
* `nativeUpdate` that simply omits an unchanged key from `patch` has the
* identical net effect (coalesceParam: the new value wins when the
* caller supplies a key, the existing column wins when it is left out) —
* the caller decides which keys to include, the same discipline
* `patchProfile` (UP7) already uses for this table. `updated_at` is
* ALWAYS stamped, even when `patch` is `{}` — matching the legacy
* statement's own unconditional `updated_at = CURRENT_TIMESTAMP` (every
* `updateUser` call runs this UPDATE regardless of what changed).
*/
async applyAdminEdit(id: number, patch: AdminEditPatch): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { ...patch, updated_at: currentTimestamp(platform) });
}
/**
* AD16 (`resetUserMfa`'s pre-write read) — `SELECT id, email, mfa_enabled
* FROM users WHERE id = ?`. Distinct from `getMfaEnabled` (MP2/AU23/AU27),
* which selects only `mfa_enabled` for a different caller.
*/
async findIdEmailMfaEnabled(id: number): Promise<AdminUserMfaRow | null> {
const row = await this.findOne({ id }, { fields: ['id', 'email', 'mfa_enabled'] });
return row ? { id: row.id, email: row.email, mfa_enabled: row.mfa_enabled ?? null } : null;
}
}
/**
+28 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { TokensModule } from '../tokens/tokens.module';
import { OauthModule } from '../oauth/oauth.module';
import { KitineraryExtractorModule } from '../booking-import/kitinerary-extractor.module';
@@ -23,9 +24,35 @@ import { PackingModule } from '../packing/packing.module';
// not @Global, so the import must be explicit.
import { PermissionsModule } from '../permissions/permissions.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { Users } from '../../db/entities/Users.entity';
import { AuditLog } from '../../db/entities/AuditLog.entity';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Addons } from '../../db/entities/Addons.entity';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity';
import { DocumentProviders } from '../../db/entities/DocumentProviders.entity';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { Places } from '../../db/entities/Places.entity';
import { TripFiles } from '../../db/entities/TripFiles.entity';
/**
* MikroOrmModule.forFeature registers the ten repositories `AdminService`'s
* `@InjectRepository` constructor needs (Plan 3i Task 1 — AD1-AD40's
* conversion off `DatabaseService`): `Users`/`AuditLog`/`AppSettings` for the
* user-CRUD + audit-log + instance-settings surface, `Addons`/
* `PhotoProviders`/`PhotoProviderFields`/`DocumentProviders` for the addon
* shelf, `McpTokens`/`OauthTokens` for the password-reset session revoke, and
* `Trips`/`Places`/`TripFiles` (owned by other domains, 3c/3e) for
* `getStats`'s three bare cross-domain counts — the same forFeature +
* @InjectRepository wiring pattern `AddonsModule` already uses.
*/
@Module({
imports: [AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule],
imports: [
MikroOrmModule.forFeature([Users, AuditLog, AppSettings, Addons, PhotoProviders, PhotoProviderFields, DocumentProviders, McpTokens, OauthTokens, Trips, Places, TripFiles]),
AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule,
],
controllers: [AdminController],
providers: [AdminService, VersionCheckJob, DemoResetJob],
})
+160 -185
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import fs from 'fs';
@@ -14,14 +15,12 @@ import { updateJwtSecret } from '../../config';
import { revokeUserSessions, revokeUserSessionsForClient } from '../../mcp/sessionManager';
import { invalidateMcpSessions } from '../../mcp';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import type { User, Addon } from '../../types';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { avatarUrl } from '../common/avatarUrl';
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/llm-config';
import { getPhotoProviderConfig } from '../memories/memories.helpers';
import { validatePassword } from '../common/passwordPolicy';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { AddonsService } from '../addons/addons.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -30,6 +29,31 @@ import { AuthService } from '../auth/auth.service';
import { PermissionsService } from '../permissions/permissions.service';
import { PERMISSION_ACTIONS } from '../permissions/permissions.service';
import { NotificationsService } from '../notifications/notifications.service';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository, AdminEditPatch } from '../../db/repositories/Users.repository';
import { AuditLog } from '../../db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../db/repositories/AuditLog.repository';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { Addons } from '../../db/entities/Addons.entity';
import type { AddonsRepository } from '../../db/repositories/Addons.repository';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import type { PhotoProvidersRepository } from '../../db/repositories/PhotoProviders.repository';
import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity';
import type { PhotoProviderFieldsRepository } from '../../db/repositories/PhotoProviderFields.repository';
import { DocumentProviders } from '../../db/entities/DocumentProviders.entity';
import type { DocumentProvidersRepository } from '../../db/repositories/DocumentProviders.repository';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import type { McpTokensRepository } from '../../db/repositories/McpTokens.repository';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import type { OauthTokensRepository } from '../../db/repositories/OauthTokens.repository';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { TripFiles } from '../../db/entities/TripFiles.entity';
import type { TripFilesRepository } from '../../db/repositories/TripFiles.repository';
import type { AddonConfig } from '../../db/entities/Addons.entity';
import {
BCRYPT_COST,
compareVersions,
@@ -48,18 +72,25 @@ const GITHUB_MAX_BYTES = 2_000_000;
const VERSION_FAILURE_TTL = 60_000;
/**
* Admin domain service — owns the admin SQL (folded from the legacy
* services/adminService.ts with the 2026-08 migration): user CRUD, instance
* stats, the permission matrix, the audit-log read side, OIDC settings, the
* demo baseline, GitHub release/version checks, invite tokens, the three
* places feature toggles, addons + photo providers, MCP tokens, OAuth sessions
* and JWT rotation.
* Admin domain service — owns admin's data access (folded from the legacy
* services/adminService.ts with the 2026-08 migration, converted onto
* repositories by Plan 3i Task 1): user CRUD, instance stats, the permission
* matrix, the audit-log read side, OIDC settings, the demo baseline, GitHub
* release/version checks, invite tokens, the three places feature toggles,
* addons + photo providers, MCP tokens, OAuth sessions and JWT rotation.
*
* Every quirk relocated byte-for-byte: the `||` falsy defaults (never `??`),
* post-insert/post-update re-selects instead of RETURNING, the COALESCE partial
* update, the #1362 guest exclusions and the exact error strings. The legacy
* `{ error, status }` envelope is the return contract — the controller's `ok()`
* helper turns it into an HttpException, so nothing here throws.
* Every quirk stays byte-for-byte: the `||` falsy defaults (never `??`),
* post-insert/post-update re-selects instead of RETURNING, the COALESCE-shaped
* partial update (now a repository call that omits unchanged keys — the same
* net effect), the #1362 guest exclusions and the exact error strings. The
* legacy `{ error, status }` envelope is the return contract — the
* controller's `ok()` helper turns it into an HttpException, so nothing here
* throws.
*
* The three self-protection invariants (own-account delete, own-MFA reset,
* last-admin demote) stay exactly where the legacy had them: plain JS
* comparisons in this service, never folded into a repository method's WHERE
* clause (R4).
*
* The bag-tracking/collab-feature toggles, user defaults, passkey reset and
* packing templates delegate to the services that own those tables. The pure
@@ -69,7 +100,18 @@ const VERSION_FAILURE_TTL = 60_000;
@Injectable()
export class AdminService {
constructor(
private readonly db: DatabaseService,
@InjectRepository(Users) private readonly users: UsersRepository,
@InjectRepository(AuditLog) private readonly auditLog: AuditLogRepository,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(Addons) private readonly addonsRepo: AddonsRepository,
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
@InjectRepository(PhotoProviderFields) private readonly photoProviderFields: PhotoProviderFieldsRepository,
@InjectRepository(DocumentProviders) private readonly documentProviders: DocumentProvidersRepository,
@InjectRepository(McpTokens) private readonly mcpTokens: McpTokensRepository,
@InjectRepository(OauthTokens) private readonly oauthTokens: OauthTokensRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@InjectRepository(Places) private readonly places: PlacesRepository,
@InjectRepository(TripFiles) private readonly tripFiles: TripFilesRepository,
private readonly addons: AddonsService,
private readonly passkeys: PasskeyService,
private readonly auth: AuthService,
@@ -85,13 +127,7 @@ export class AdminService {
async listUsers() {
// Guests (#1362) are accountless trip participants, not real users — keep them out
// of admin user management entirely.
const users = this.db.all<
Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & {
avatar?: string | null;
}
>(
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC',
);
const users = await this.users.listForAdmin();
let onlineUserIds = new Set<number>();
try {
// The catch stays here rather than in the facade: an admin list that shows
@@ -128,36 +164,33 @@ export class AdminService {
}
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
const existingUsername = this.db.get('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0', username);
const existingUsername = await this.users.findIdByUsernameExact(username);
if (existingUsername) return { error: 'Username already taken', status: 409 };
const existingEmail = this.db.get('SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0', email);
const existingEmail = await this.users.findIdByEmailExact(email);
if (existingEmail) return { error: 'Email already taken', status: 409 };
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
const result = this.db.run(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)',
username, email, passwordHash, data.role || 'user',
);
const insertedId = await this.users.insertAdminCreatedUser({
username, email, password_hash: passwordHash, role: data.role || 'user',
});
const user = this.db.get(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?',
result.lastInsertRowid,
);
const user = await this.users.findAdminSummary(insertedId);
return {
user,
insertedId: Number(result.lastInsertRowid),
insertedId,
auditDetails: { username, email, role: data.role || 'user' },
};
}
async updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
const userId = Number(id);
const username = typeof data.username === 'string' ? data.username.trim() : data.username;
const email = typeof data.email === 'string' ? data.email.trim() : data.email;
const { role, password } = data;
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', id);
const user = await this.users.findById(userId);
if (!user) return { error: 'User not found', status: 404 };
@@ -171,11 +204,11 @@ export class AdminService {
if (email === '') return { error: 'Email cannot be empty', status: 400 };
if (username && username !== user.username) {
const conflict = this.db.get('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0', username, id);
const conflict = await this.users.findIdByUsernameExactExcluding(username, userId);
if (conflict) return { error: 'Username already taken', status: 409 };
}
if (email && email !== user.email) {
const conflict = this.db.get('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0', email, id);
const conflict = await this.users.findIdByEmailExactExcluding(email, userId);
if (conflict) return { error: 'Email already taken', status: 409 };
}
@@ -187,10 +220,13 @@ export class AdminService {
// Don't let the admin UI demote the last remaining admin — that would leave the
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
// SECURITY: plain JS comparisons, deliberately never folded into a
// repository method's WHERE clause (R4) — two repository reads, one
// service-level `if`.
if (role && role !== 'admin') {
const current = this.db.get<{ role?: string }>('SELECT role FROM users WHERE id = ?', id);
if (current?.role === 'admin') {
const adminCount = this.db.get<{ count: number }>("SELECT COUNT(*) as count FROM users WHERE role = 'admin'")!.count;
const currentRole = await this.users.getRole(userId);
if (currentRole === 'admin') {
const adminCount = await this.users.countAdmins();
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
}
}
@@ -200,33 +236,31 @@ export class AdminService {
// accepting every cookie the intruder already holds, so the one action taken
// to lock them out was the one action that did not. Both self-service paths
// (changePassword, resetPassword) have always done this; this one had not.
const newPv = password ? ((user as User & { password_version?: number }).password_version ?? 0) + 1 : null;
const newPv = password ? (user.password_version ?? 0) + 1 : null;
const patch: AdminEditPatch = {};
if (username) patch.username = username;
if (email) patch.email = email;
if (role) patch.role = role;
if (password) {
patch.password_hash = passwordHash!;
patch.password_version = newPv!;
}
// The password-reset transaction's CURRENT boundary, preserved exactly
// (R4): the users UPDATE, the mcp_tokens DELETE and the oauth_tokens
// revoke stay inside the SAME uow.transactional call, never split across
// separate un-transacted repository calls.
await this.uow.transactional(async () => {
this.db.run(
`
UPDATE users SET
username = COALESCE(?, username),
email = COALESCE(?, email),
role = COALESCE(?, role),
password_hash = COALESCE(?, password_hash),
password_version = COALESCE(?, password_version),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
username || null, email || null, role || null, passwordHash, newPv, id,
);
await this.users.applyAdminEdit(userId, patch);
if (password) {
// The version bump only invalidates JWT cookies. These two stores carry
// their own credentials and are revoked separately, exactly as the
// self-service paths do it.
this.db.run('DELETE FROM mcp_tokens WHERE user_id = ?', id);
await this.mcpTokens.deleteAllForUser(userId);
try {
this.db.run(
'UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL',
id,
);
await this.oauthTokens.revokeAllForUser(userId);
} catch { /* very old installs predate oauth_tokens */ }
}
});
@@ -235,7 +269,7 @@ export class AdminService {
try { revokeUserSessions(Number(id)); } catch { /* best-effort, same as elsewhere */ }
}
const updated = this.db.get('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?', id);
const updated = await this.users.findAdminSummary(userId);
const changed: string[] = [];
if (username) changed.push('username');
@@ -251,11 +285,13 @@ export class AdminService {
}
async deleteUser(id: string, currentUserId: number) {
// SECURITY: plain JS comparison, deliberately kept exactly here — never
// folded into a repository method's WHERE clause (R4).
if (Number.parseInt(id) === currentUserId) {
return { error: 'Cannot delete own account', status: 400 };
}
const userToDel = this.db.get<{ id: number; email: string }>('SELECT id, email FROM users WHERE id = ?', id);
const userToDel = await this.users.findIdAndEmail(Number(id));
if (!userToDel) return { error: 'User not found', status: 404 };
await this.userCleanup.deleteUserCompletely(userToDel.id);
@@ -280,21 +316,17 @@ export class AdminService {
*/
async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> {
const targetId = Number(id);
// SECURITY: plain JS comparison, deliberately kept exactly here — never
// folded into a repository method's WHERE clause (R4).
if (targetId === actingUserId) {
return { error: 'Use Settings to change your own two-factor setup', status: 400 };
}
const target = this.db.get<{ id: number; email: string; mfa_enabled: number | boolean }>(
'SELECT id, email, mfa_enabled FROM users WHERE id = ?',
targetId,
);
const target = await this.users.findIdEmailMfaEnabled(targetId);
if (!target) return { error: 'User not found', status: 404 };
// Same three columns disableMfa clears, so an admin reset and a self-service
// disable leave the account in exactly one state rather than two.
this.db.run(
'UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
targetId,
);
await this.users.disableMfa(targetId);
return { success: true, email: target.email };
}
@@ -302,10 +334,10 @@ export class AdminService {
// ── Stats ──────────────────────────────────────────────────────────────────
async getStats() {
const totalUsers = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const totalTrips = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM trips')!.count;
const totalPlaces = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM places')!.count;
const totalFiles = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM trip_files')!.count;
const totalUsers = await this.users.countNonGuest();
const totalTrips = await this.trips.count();
const totalPlaces = await this.places.count();
const totalFiles = await this.tripFiles.count();
return { totalUsers, totalTrips, totalPlaces, totalFiles };
}
@@ -335,30 +367,8 @@ export class AdminService {
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
type Row = {
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
};
const rows = this.db.all<Row>(
`
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?
`,
limit, offset,
);
const total = this.db.get<{ c: number }>('SELECT COUNT(*) as c FROM audit_log')!.c;
const rows = await this.auditLog.listPage(limit, offset);
const total = await this.auditLog.count();
const entries = rows.map((r) => {
// Unparseable details fall back to the raw string rather than the old
@@ -516,15 +526,14 @@ export class AdminService {
const result = await this.checkVersion();
if (!result.update_available) return;
const lastNotified = this.db.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?', 'last_notified_version',
)?.value;
const lastNotified = await this.appSettings.getValue('last_notified_version');
if (lastNotified === result.latest) return;
this.db.run(
'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
'last_notified_version', result.latest,
);
// INSERT OR REPLACE shape — the SAME dialect storage's `upsertOrReplace`
// (Plan 3i Task 2, SS1) already added to AppSettingsRepository, reused
// here rather than adding a second method for the identical SQL text
// (`INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)`).
await this.appSettings.upsertOrReplace('last_notified_version', result.latest);
await this.notifications.send({
event: 'version_available',
@@ -541,8 +550,7 @@ export class AdminService {
// ── Addons ─────────────────────────────────────────────────────────────────
async listAddons() {
const addons = this.db
.all<Addon>('SELECT * FROM addons ORDER BY sort_order, id')
const addons = (await this.addonsRepo.listAllOrdered())
// Hidden rather than shown-and-refused, because a toggle that answers 403
// is worse than no toggle.
//
@@ -561,38 +569,12 @@ export class AdminService {
a.id === ADDON_IDS.DAWARICH)
),
);
const providers = this.db.all<{
id: string;
name: string;
description?: string | null;
icon: string;
enabled: number;
sort_order: number;
}>(`
SELECT id, name, description, icon, enabled, sort_order
FROM photo_providers
ORDER BY sort_order, id
`)
const providers = (await this.photoProviders.listAllOrdered())
// Immich and Synology Photos are servers the admin runs at home. A managed
// instance cannot reach one (its egress does not go there, and it should
// not), so offering the connection would only produce a timeout.
.filter(() => !readEnv().managed.enabled);
const fields = this.db.all<{
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder?: string | null;
required: number;
secret: number;
settings_key?: string | null;
payload_key?: string | null;
sort_order: number;
}>(`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id
`);
const fields = await this.photoProviderFields.listAllOrderedForAdminShelf();
const fieldsByProvider = new Map<string, typeof fields>();
for (const field of fields) {
const arr = fieldsByProvider.get(field.provider_id) || [];
@@ -609,18 +591,8 @@ export class AdminService {
// not belong to a user here but to a trip, so they are entered in the trip
// rather than in settings. The admin decides only whether a provider may be
// offered at all.
const docProviders = this.db.all<{
id: string;
name: string;
description?: string | null;
icon: string;
enabled: number;
sort_order: number;
}>(`
SELECT id, name, description, icon, enabled, sort_order
FROM document_providers
ORDER BY sort_order, id
`).filter(() => !readEnv().managed.enabled);
const docProviders = (await this.documentProviders.listAllOrdered())
.filter(() => !readEnv().managed.enabled);
return [
...addons.map((a) => ({
@@ -628,8 +600,8 @@ export class AdminService {
enabled: !!a.enabled,
config:
a.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
: JSON.parse(a.config || '{}'),
? maskLlmAddonConfig(a.config ?? {})
: (a.config ?? {}),
})),
...providers.map((p) => ({
id: p.id,
@@ -638,8 +610,8 @@ export class AdminService {
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
config: getPhotoProviderConfig(p.id!),
fields: (fieldsByProvider.get(p.id!) || []).map((f) => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
@@ -667,10 +639,9 @@ export class AdminService {
}
async updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
type ProviderRow = { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number };
const addon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
const provider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
const docProvider = this.db.get<ProviderRow>('SELECT * FROM document_providers WHERE id = ?', id);
const addon = await this.addonsRepo.findById(id);
const provider = await this.photoProviders.findById(id);
const docProvider = await this.documentProviders.findById(id);
if (!addon && !provider && !docProvider) return { error: 'Addon not found', status: 404 };
// The whole addon, not just its config: on a centrally administered install
@@ -696,46 +667,46 @@ export class AdminService {
}
await this.uow.transactional(async () => {
if (addon) {
if (data.enabled !== undefined) {
this.db.run('UPDATE addons SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
// Journey off takes its providers with it: a row left enabled would
// resurface the moment journey returns, which nobody switched on.
if (id === ADDON_IDS.JOURNEY && !data.enabled)
this.db.run('UPDATE photo_providers SET enabled = 0');
// Documents off takes its providers with it, for the reason above: a
// row left enabled would resurface the moment the addon returns.
if (id === ADDON_IDS.DOCUMENTS && !data.enabled)
this.db.run('UPDATE document_providers SET enabled = 0');
if (addon) {
if (data.enabled !== undefined) {
await this.addonsRepo.setEnabled(id, !!data.enabled);
// Journey off takes its providers with it: a row left enabled would
// resurface the moment journey returns, which nobody switched on.
if (id === ADDON_IDS.JOURNEY && !data.enabled)
await this.photoProviders.disableAll();
// Documents off takes its providers with it, for the reason above: a
// row left enabled would resurface the moment the addon returns.
if (id === ADDON_IDS.DOCUMENTS && !data.enabled)
await this.documentProviders.disableAll();
}
if (data.config !== undefined) {
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
const configToStore =
id === ADDON_IDS.LLM_PARSING
? prepareLlmAddonConfigForWrite(data.config, addon.config ?? {})
: data.config;
await this.addonsRepo.setConfig(id, configToStore as AddonConfig);
}
} else if (provider) {
if (data.enabled !== undefined)
await this.photoProviders.setEnabled(id, data.enabled ? 1 : 0);
} else {
if (data.enabled !== undefined)
await this.documentProviders.setEnabled(id, data.enabled ? 1 : 0);
}
if (data.config !== undefined) {
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
const configToStore =
id === ADDON_IDS.LLM_PARSING
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
: data.config;
this.db.run('UPDATE addons SET config = ? WHERE id = ?', JSON.stringify(configToStore), id);
}
} else if (provider) {
if (data.enabled !== undefined)
this.db.run('UPDATE photo_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
} else {
if (data.enabled !== undefined)
this.db.run('UPDATE document_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
}
});
const updatedAddon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
const updatedProvider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
const updatedAddon = await this.addonsRepo.findById(id);
const updatedProvider = await this.photoProviders.findById(id);
const updated = updatedAddon
? {
...updatedAddon,
enabled: !!updatedAddon.enabled,
config:
updatedAddon.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
: JSON.parse(updatedAddon.config || '{}'),
? maskLlmAddonConfig(updatedAddon.config ?? {})
: (updatedAddon.config ?? {}),
}
: updatedProvider
? {
@@ -745,7 +716,7 @@ export class AdminService {
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
config: getPhotoProviderConfig(updatedProvider.id),
config: getPhotoProviderConfig(updatedProvider.id!),
sort_order: updatedProvider.sort_order,
}
: null;
@@ -761,7 +732,11 @@ export class AdminService {
// after an admin switched them off, while the REST half answered 404 for the
// same user in the same moment.
const MCP_RELEVANT_ADDONS = new Set<string>(MCP_GATED_ADDON_IDS);
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
// `addon.enabled` is a JS boolean now (the repository's `AddonRow.enabled`
// shape, not the raw stored int the legacy `SELECT *` returned) — comparing
// two booleans for a real flip is the same predicate the legacy int
// comparison (`(data.enabled ? 1 : 0) !== addon.enabled`) expressed.
const enabledChanged = !!addon && data.enabled !== undefined && !!data.enabled !== addon.enabled;
return {
addon: updated,