From 2efd509df5be144503e29301d043d63cf6867728 Mon Sep 17 00:00:00 2001 From: alexcibotari Date: Sat, 10 Oct 2026 15:48:51 +0200 Subject: [PATCH] feat: migration API for importing spaces into a self-hosted Localess install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - functions: read-only /api/migration/** (spaces, space, tokens, webhooks, schemas; translations, assets, contents paged by 500), bearer migration token, 404 while no token is configured - functions: admin-only migrationtoken-generate / -revoke callables; only the token's sha256 is stored (configs/migration) - firestore.rules: configs/migration is read by admins only and written only by the callables - admin UI: Settings → Migration tab to generate (shown once), regenerate and revoke the token - hosting: /api/migration/** rewrite; README: moving to a self-hosted install --- README.md | 12 ++ firebase.json | 5 + firestore.rules | 6 +- functions/src/index.ts | 4 + functions/src/migration-token.test.ts | 45 ++++++++ functions/src/migration-token.ts | 26 +++++ functions/src/migration.test.ts | 102 +++++++++++++++++ functions/src/migration.ts | 107 ++++++++++++++++++ functions/src/utils/migration-token.test.ts | 27 +++++ functions/src/utils/migration-token.ts | 42 +++++++ .../migration/migration.component.html | 29 +++++ .../migration/migration.component.spec.ts | 63 +++++++++++ .../settings/migration/migration.component.ts | 58 ++++++++++ .../admin/settings/settings-routing.module.ts | 5 + .../admin/settings/settings.component.ts | 8 +- src/app/shared/services/migration.service.ts | 26 +++++ 16 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 functions/src/migration-token.test.ts create mode 100644 functions/src/migration-token.ts create mode 100644 functions/src/migration.test.ts create mode 100644 functions/src/migration.ts create mode 100644 functions/src/utils/migration-token.test.ts create mode 100644 functions/src/utils/migration-token.ts create mode 100644 src/app/features/admin/settings/migration/migration.component.html create mode 100644 src/app/features/admin/settings/migration/migration.component.spec.ts create mode 100644 src/app/features/admin/settings/migration/migration.component.ts create mode 100644 src/app/shared/services/migration.service.ts diff --git a/README.md b/README.md index 722d22e21..1d6175d07 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,18 @@ Thank you for considering sponsoring us on GitHub! 2. [Setup](https://github.com/Lessify/localess/wiki/Setup) 3. [Integration](https://github.com/Lessify/localess/wiki/Integration) +## Moving to a self-hosted install + +A self-hosted Localess install imports spaces from this environment, one space at a time: + +1. Deploy this version (functions and hosting) so `/api/migration/**` exists. +2. In Admin → Settings → Migration, generate a migration token and copy it (it is shown once). +3. In the self-hosted install, Admin → Spaces → Import from Firebase: enter this environment's URL and the token, + pick a space, import. Repeat per space. +4. Revoke the token when the move is done. + +The migration API is read-only and answers `404` while no token is configured. + ## How it works **Localess** is using Firebase products to run the application. diff --git a/firebase.json b/firebase.json index 2475918e5..1b1d88515 100644 --- a/firebase.json +++ b/firebase.json @@ -63,6 +63,11 @@ "function": "publicv1", "region": "europe-west6" }, + { + "source": "/api/migration/**", + "function": "migrationapi", + "region": "europe-west6" + }, { "source": "**", "destination": "/index.html" diff --git a/firestore.rules b/firestore.rules index b3a9d6772..5c54bf827 100644 --- a/firestore.rules +++ b/firestore.rules @@ -154,8 +154,10 @@ service cloud.firestore { // Configs match /configs/{configId} { - allow read: if isSignedIn() && (hasRole('admin') || hasPermission('SETTINGS_MANAGEMENT')); - allow write: if isSignedIn() && (hasRole('admin') || hasPermission('SETTINGS_MANAGEMENT')); + // configs/migration holds the migration token's hash: only admins read it, and only the admin-only + // migrationtoken callables (Admin SDK) write it, so nobody can set a token of their choosing. + allow read: if isSignedIn() && (hasRole('admin') || (configId != 'migration' && hasPermission('SETTINGS_MANAGEMENT'))); + allow write: if isSignedIn() && configId != 'migration' && (hasRole('admin') || hasPermission('SETTINGS_MANAGEMENT')); } } } diff --git a/functions/src/index.ts b/functions/src/index.ts index dc5f2f947..59e0852e6 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -29,6 +29,10 @@ export { user } from './users'; export { webhook } from './webhooks'; +export { migrationapi } from './migration'; + +export { migrationtoken } from './migration-token'; + export { v1 as publicv1 } from './v1'; // Plugins API diff --git a/functions/src/migration-token.test.ts b/functions/src/migration-token.test.ts new file mode 100644 index 000000000..ae72c15a1 --- /dev/null +++ b/functions/src/migration-token.test.ts @@ -0,0 +1,45 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hashMigrationToken } from './utils/migration-token'; + +let handlers: { generate: (req: unknown) => Promise<{ token: string }>; revoke: (req: unknown) => Promise }; +let written: unknown; +let deleted: boolean; + +beforeAll(async () => { + process.env['FIREBASE_CONFIG'] = JSON.stringify({ projectId: 'test-project', storageBucket: 'test-project.appspot.com' }); + process.env['GCLOUD_PROJECT'] = 'test-project'; + const cfg = await import('./config'); + vi.spyOn(cfg.firestoreService, 'doc').mockImplementation((() => ({ + set: async (value: unknown) => void (written = value), + delete: async () => void (deleted = true), + })) as never); + const mod = await import('./migration-token'); + handlers = { generate: mod.migrationtoken.generate.run as never, revoke: mod.migrationtoken.revoke.run as never }; +}); + +beforeEach(() => { + written = undefined; + deleted = false; +}); + +const admin = { auth: { uid: 'u', token: { role: 'admin' } } }; +const editor = { auth: { uid: 'u', token: { role: 'custom' } } }; + +describe('migration token callables', () => { + it('generates a token for admins and stores only its hash', async () => { + const { token } = await handlers.generate({ ...admin, data: {} }); + expect(token).toMatch(/^[A-Za-z0-9_-]{40}$/); + expect(written).toMatchObject({ tokenHash: hashMigrationToken(token) }); + expect(JSON.stringify(written)).not.toContain(token); + }); + + it('revokes for admins', async () => { + await handlers.revoke({ ...admin, data: {} }); + expect(deleted).toBe(true); + }); + + it('refuses everyone else', async () => { + await expect(handlers.generate({ ...editor, data: {} })).rejects.toThrow('permission-denied'); + await expect(handlers.revoke({ data: {} })).rejects.toThrow('permission-denied'); + }); +}); diff --git a/functions/src/migration-token.ts b/functions/src/migration-token.ts new file mode 100644 index 000000000..98c2ca896 --- /dev/null +++ b/functions/src/migration-token.ts @@ -0,0 +1,26 @@ +import { logger } from 'firebase-functions'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { FieldValue } from 'firebase-admin/firestore'; +import { firestoreService, ROLE_ADMIN } from './config'; +import { hasRole } from './utils/user-auth-utils'; +import { authUid } from './utils/log-auth'; +import { hashMigrationToken, MIGRATION_CONFIG_PATH, newMigrationToken } from './utils/migration-token'; + +/** Admin → Settings → Migration: a new token replaces the old one; it is returned once and only its hash is kept. */ +const generate = onCall>(async request => { + logger.info('[MigrationToken::generate] auth uid: ' + authUid(request.auth)); + if (!hasRole(ROLE_ADMIN, request.auth)) throw new HttpsError('permission-denied', 'permission-denied'); + const token = newMigrationToken(); + const createdAt = new Date(); + await firestoreService.doc(MIGRATION_CONFIG_PATH).set({ tokenHash: hashMigrationToken(token), createdAt: FieldValue.serverTimestamp() }); + return { token, createdAt: createdAt.toISOString() }; +}); + +/** Turns the migration API off (it answers 404 again). */ +const revoke = onCall>(async request => { + logger.info('[MigrationToken::revoke] auth uid: ' + authUid(request.auth)); + if (!hasRole(ROLE_ADMIN, request.auth)) throw new HttpsError('permission-denied', 'permission-denied'); + await firestoreService.doc(MIGRATION_CONFIG_PATH).delete(); +}); + +export const migrationtoken = { generate, revoke }; diff --git a/functions/src/migration.test.ts b/functions/src/migration.test.ts new file mode 100644 index 000000000..a0e6b8901 --- /dev/null +++ b/functions/src/migration.test.ts @@ -0,0 +1,102 @@ +import express from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hashMigrationToken } from './utils/migration-token'; + +type Doc = { id: string; data: Record }; +let collections: Record; +let config: Record | undefined; +let MIGRATION: express.Router; +let firestoreService: { doc: (path: string) => unknown; collection: (path: string) => unknown }; + +const ts = (iso: string) => ({ toDate: () => new Date(iso) }); + +/** A Firestore stand-in for the queries the router makes: doc get, collection orderBy/startAfter/limit/get. */ +function fakeCollection(path: string) { + const query = (startAfter?: string, limit?: number) => ({ + orderBy: () => query(startAfter, limit), + startAfter: (id: string) => query(id, limit), + limit: (n: number) => query(startAfter, n), + get: async () => { + const all = [...(collections[path] ?? [])].sort((a, b) => (a.id < b.id ? -1 : 1)); + const from = startAfter ? all.filter(it => it.id > startAfter) : all; + const docs = (limit ? from.slice(0, limit) : from).map(it => ({ id: it.id, data: () => it.data })); + return { docs, empty: docs.length === 0 }; + }, + }); + return query(); +} + +beforeAll(async () => { + process.env['FIREBASE_CONFIG'] = JSON.stringify({ projectId: 'test-project', storageBucket: 'test-project.appspot.com' }); + process.env['GCLOUD_PROJECT'] = 'test-project'; + const cfg = await import('./config'); + firestoreService = cfg.firestoreService as never; + MIGRATION = (await import('./migration')).MIGRATION; +}); + +beforeEach(() => { + vi.restoreAllMocks(); + config = { tokenHash: hashMigrationToken('secret') }; + collections = { + spaces: [{ id: 's1', data: { name: 'Site', locales: [{ id: 'en', name: 'English' }], localeFallback: { id: 'en', name: 'English' }, createdAt: ts('2025-01-01T00:00:00Z') } }], + 'spaces/s1/tokens': [{ id: 'TOKEN000000000000001', data: { name: 'web', createdAt: ts('2025-01-02T00:00:00Z') } }], + 'spaces/s1/webhooks': [], + 'spaces/s1/schemas': [{ id: 'page', data: { type: 'ROOT', fields: [] } }], + 'spaces/s1/translations': Array.from({ length: 501 }, (_, i) => ({ id: `k${String(i).padStart(4, '0')}`, data: { type: 'STRING', locales: { en: 'x' } } })), + 'spaces/s1/assets': [], + 'spaces/s1/contents': [{ id: 'c1', data: { kind: 'DOCUMENT', data: '{"_id":"r"}' } }], + }; + vi.spyOn(firestoreService, 'doc').mockImplementation(((path: string) => ({ + get: async () => { + if (path === 'configs/migration') return { exists: !!config, data: () => config }; + const [, id] = path.split('/'); + const found = collections['spaces'].find(it => it.id === id); + return { exists: !!found, id, data: () => found?.data }; + }, + })) as never); + vi.spyOn(firestoreService, 'collection').mockImplementation(((path: string) => fakeCollection(path)) as never); +}); + +const app = () => express().use(MIGRATION); +const get = (url: string, token: string | null = 'secret') => { + const req = request(app()).get(url); + return token ? req.set('Authorization', `Bearer ${token}`) : req; +}; + +describe('migration API', () => { + it('answers 404 everywhere while no token is configured', async () => { + config = undefined; + expect((await get('/api/migration/spaces')).status).toBe(404); + }); + + it('answers 401 without or with a wrong token', async () => { + expect((await get('/api/migration/spaces', null)).status).toBe(401); + expect((await get('/api/migration/spaces', 'wrong')).status).toBe(401); + }); + + it('lists spaces and returns one with ISO timestamps', async () => { + expect((await get('/api/migration/spaces')).body).toEqual([{ id: 's1', name: 'Site', createdAt: '2025-01-01T00:00:00.000Z' }]); + expect((await get('/api/migration/spaces/s1')).body).toMatchObject({ id: 's1', name: 'Site', createdAt: '2025-01-01T00:00:00.000Z' }); + expect((await get('/api/migration/spaces/nope')).status).toBe(404); + }); + + it('returns small collections whole, with ids', async () => { + expect((await get('/api/migration/spaces/s1/tokens')).body).toEqual([{ id: 'TOKEN000000000000001', name: 'web', createdAt: '2025-01-02T00:00:00.000Z' }]); + expect((await get('/api/migration/spaces/s1/schemas')).body).toEqual([{ id: 'page', type: 'ROOT', fields: [] }]); + expect((await get('/api/migration/spaces/s1/webhooks')).body).toEqual([]); + }); + + it('pages large collections by 500, cursor = last id, null at the end', async () => { + const first = (await get('/api/migration/spaces/s1/translations')).body; + expect(first.items).toHaveLength(500); + expect(first.cursor).toBe('k0499'); + const second = (await get('/api/migration/spaces/s1/translations?cursor=k0499')).body; + expect(second.items.map((it: { id: string }) => it.id)).toEqual(['k0500']); + expect(second.cursor).toBeNull(); + }); + + it('returns contents as stored, data string included', async () => { + expect((await get('/api/migration/spaces/s1/contents')).body).toEqual({ items: [{ id: 'c1', kind: 'DOCUMENT', data: '{"_id":"r"}' }], cursor: null }); + }); +}); diff --git a/functions/src/migration.ts b/functions/src/migration.ts new file mode 100644 index 000000000..7271be622 --- /dev/null +++ b/functions/src/migration.ts @@ -0,0 +1,107 @@ +import express, { NextFunction, Request, Response } from 'express'; +import { onRequest } from 'firebase-functions/v2/https'; +import { firestoreService } from './config'; +import { checkMigrationToken, MIGRATION_CONFIG_PATH, MigrationConfig } from './utils/migration-token'; + +/** + * Read-only API a self-hosted Localess install calls to import a space (Admin → Spaces → Import from Firebase). + * Disabled (404) until an admin generates a migration token in Admin → Settings → Migration. + */ +const PAGE_SIZE = 500; + +/** + * Firestore Timestamps (at any depth) → ISO strings; everything else as stored. + * @param {unknown} value a Firestore value + * @return {unknown} its JSON form + */ +function plain(value: unknown): unknown { + if (value && typeof value === 'object') { + const ts = value as { toDate?: () => Date }; + if (typeof ts.toDate === 'function') return ts.toDate().toISOString(); + if (Array.isArray(value)) return value.map(plain); + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)])); + } + return value; +} + +const toJson = (doc: { id: string; data: () => unknown }) => ({ id: doc.id, ...(plain(doc.data()) as object) }); + +/** + * Answers 404 while no migration token is configured, 401 for a missing or wrong one. + * @param {Request} req request + * @param {Response} res response + * @param {NextFunction} next next handler + */ +async function auth(req: Request, res: Response, next: NextFunction): Promise { + const config = (await firestoreService.doc(MIGRATION_CONFIG_PATH).get()).data() as MigrationConfig | undefined; + const result = checkMigrationToken(req.header('authorization'), config); + if (result === 'disabled') { + res.status(404).json({ message: 'Not found' }); + return; + } + if (result === 'unauthorized') { + res.status(401).json({ message: 'Missing or invalid migration token' }); + return; + } + next(); +} + +/** + * Answers 404 for an unknown space. + * @param {Request} req request + * @param {Response} res response + * @return {Promise} whether the space exists + */ +async function requireSpace(req: Request, res: Response): Promise { + const doc = await firestoreService.doc(`spaces/${req.params['spaceId']}`).get(); + if (!doc.exists) res.status(404).json({ message: 'Space not found' }); + return doc.exists; +} + +// eslint-disable-next-line new-cap +export const MIGRATION = express.Router(); +MIGRATION.use('/api/migration', auth); + +MIGRATION.get('/api/migration/spaces', async (_req, res) => { + const snapshot = await firestoreService.collection('spaces').orderBy('__name__').get(); + res.json( + snapshot.docs.map(doc => { + const space = toJson(doc) as { id: string; name?: string; createdAt?: string }; + return { id: space.id, name: space.name, createdAt: space.createdAt }; + }) + ); +}); + +MIGRATION.get('/api/migration/spaces/:spaceId', async (req, res) => { + const doc = await firestoreService.doc(`spaces/${req.params['spaceId']}`).get(); + if (!doc.exists) { + res.status(404).json({ message: 'Space not found' }); + return; + } + res.json(toJson({ id: doc.id, data: () => doc.data() })); +}); + +for (const name of ['tokens', 'webhooks', 'schemas']) { + MIGRATION.get(`/api/migration/spaces/:spaceId/${name}`, async (req, res) => { + if (!(await requireSpace(req, res))) return; + const snapshot = await firestoreService.collection(`spaces/${req.params['spaceId']}/${name}`).orderBy('__name__').get(); + res.json(snapshot.docs.map(toJson)); + }); +} + +for (const name of ['translations', 'assets', 'contents']) { + MIGRATION.get(`/api/migration/spaces/:spaceId/${name}`, async (req, res) => { + if (!(await requireSpace(req, res))) return; + let query = firestoreService.collection(`spaces/${req.params['spaceId']}/${name}`).orderBy('__name__'); + const cursor = typeof req.query['cursor'] === 'string' ? req.query['cursor'] : undefined; + if (cursor) query = query.startAfter(cursor); + const snapshot = await query.limit(PAGE_SIZE).get(); + const items = snapshot.docs.map(toJson); + res.json({ items, cursor: items.length === PAGE_SIZE ? items[items.length - 1].id : null }); + }); +} + +const app = express(); +app.use(MIGRATION); + +export const migrationapi = onRequest({ memory: '512MiB', maxInstances: 2 }, app); diff --git a/functions/src/utils/migration-token.test.ts b/functions/src/utils/migration-token.test.ts new file mode 100644 index 000000000..063315249 --- /dev/null +++ b/functions/src/utils/migration-token.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { checkMigrationToken, hashMigrationToken, newMigrationToken } from './migration-token'; + +describe('migration token', () => { + it('makes 40 url-safe characters, different every time', () => { + const a = newMigrationToken(); + expect(a).toMatch(/^[A-Za-z0-9_-]{40}$/); + expect(newMigrationToken()).not.toBe(a); + }); + + it('hashes with sha256 hex', () => { + expect(hashMigrationToken('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + }); + + it('is disabled without a stored hash', () => { + expect(checkMigrationToken('Bearer x', undefined)).toBe('disabled'); + expect(checkMigrationToken('Bearer x', {})).toBe('disabled'); + }); + + it('accepts only the matching bearer token', () => { + const stored = { tokenHash: hashMigrationToken('secret') }; + expect(checkMigrationToken('Bearer secret', stored)).toBe('ok'); + expect(checkMigrationToken('Bearer other', stored)).toBe('unauthorized'); + expect(checkMigrationToken('secret', stored)).toBe('unauthorized'); + expect(checkMigrationToken(undefined, stored)).toBe('unauthorized'); + }); +}); diff --git a/functions/src/utils/migration-token.ts b/functions/src/utils/migration-token.ts new file mode 100644 index 000000000..4cc59eab9 --- /dev/null +++ b/functions/src/utils/migration-token.ts @@ -0,0 +1,42 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +/** Firestore document holding the hash of the environment's migration token (see the self-hosted import). */ +export const MIGRATION_CONFIG_PATH = 'configs/migration'; + +export interface MigrationConfig { + tokenHash?: string; + createdAt?: unknown; +} + +/** + * A new migration token: 30 random bytes as 40 url-safe characters. Shown once, only its hash is stored. + * @return {string} the token + */ +export function newMigrationToken(): string { + return randomBytes(30).toString('base64url'); +} + +/** + * sha256 of a migration token, as stored in `configs/migration`. + * @param {string} token the token + * @return {string} hex digest + */ +export function hashMigrationToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** + * `disabled` when no token is configured (the API answers 404 then), `ok` for `Authorization: Bearer ` + * matching the stored hash, `unauthorized` otherwise. + * @param {string | undefined} header the request's Authorization header + * @param {MigrationConfig | undefined} stored the `configs/migration` document + * @return {string} the outcome + */ +export function checkMigrationToken(header: string | undefined, stored: MigrationConfig | undefined): 'ok' | 'disabled' | 'unauthorized' { + if (!stored?.tokenHash) return 'disabled'; + const match = /^Bearer (.+)$/.exec(header ?? ''); + if (!match) return 'unauthorized'; + const given = Buffer.from(hashMigrationToken(match[1]), 'hex'); + const expected = Buffer.from(stored.tokenHash, 'hex'); + return given.length === expected.length && timingSafeEqual(given, expected) ? 'ok' : 'unauthorized'; +} diff --git a/src/app/features/admin/settings/migration/migration.component.html b/src/app/features/admin/settings/migration/migration.component.html new file mode 100644 index 000000000..d24004dbd --- /dev/null +++ b/src/app/features/admin/settings/migration/migration.component.html @@ -0,0 +1,29 @@ +
+

+ A self-hosted Localess install imports spaces from this environment with this token (Admin → Spaces → Import from Firebase). Only admins + can see the data; the token reads every space. +

+ @if (error(); as message) { +

{{ message }}

+ } + @if (newToken(); as token) { +
+

Copy the token now, it is not shown again.

+ {{ token }} +
+ + +
+
+ } + @if (configuredAt(); as at) { +

Migration API enabled since {{ at | date: 'medium' }}.

+
+ + +
+ } @else { +

Migration API disabled.

+ + } +
diff --git a/src/app/features/admin/settings/migration/migration.component.spec.ts b/src/app/features/admin/settings/migration/migration.component.spec.ts new file mode 100644 index 000000000..dee19fa23 --- /dev/null +++ b/src/app/features/admin/settings/migration/migration.component.spec.ts @@ -0,0 +1,63 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { MigrationService } from '@shared/services/migration.service'; +import { MigrationComponent } from './migration.component'; + +describe('MigrationComponent', () => { + function setup(createdAt: string | undefined) { + const service = { + status: vi.fn().mockReturnValue(of({ createdAt })), + generate: vi.fn().mockReturnValue(of({ token: 'T'.repeat(40), createdAt: '2026-10-10T00:00:00.000Z' })), + revoke: vi.fn().mockReturnValue(of(undefined)), + }; + TestBed.overrideComponent(MigrationComponent, { set: { template: '
' } }); + TestBed.configureTestingModule({ providers: [{ provide: MigrationService, useValue: service }] }); + const fixture = TestBed.createComponent(MigrationComponent); + fixture.detectChanges(); + return { component: fixture.componentInstance, service }; + } + + it('shows whether a token is configured', () => { + expect(setup('2026-01-01T00:00:00.000Z').component.configuredAt()).toBe('2026-01-01T00:00:00.000Z'); + TestBed.resetTestingModule(); + expect(setup(undefined).component.configuredAt()).toBeUndefined(); + }); + + it('shows a generated token once', () => { + const { component } = setup(undefined); + component.generate(); + expect(component.newToken()).toBe('T'.repeat(40)); + expect(component.configuredAt()).toBe('2026-10-10T00:00:00.000Z'); + component.dismissToken(); + expect(component.newToken()).toBeUndefined(); + }); + + it('shows why generating or revoking failed instead of failing silently', () => { + const { component, service } = setup(undefined); + service.generate.mockReturnValue(throwError(() => ({ code: 'functions/permission-denied' }))); + component.generate(); + expect(component.error()).toBe('Only admins can manage the migration token.'); + expect(component.newToken()).toBeUndefined(); + service.revoke.mockReturnValue(throwError(() => new Error('offline'))); + component.revoke(); + expect(component.error()).toBe('The migration token could not be changed.'); + }); + + it('explains that only admins see the token status when reading it is refused', () => { + TestBed.overrideComponent(MigrationComponent, { set: { template: '
' } }); + TestBed.configureTestingModule({ + providers: [{ provide: MigrationService, useValue: { status: vi.fn().mockReturnValue(throwError(() => ({ code: 'permission-denied' }))) } }], + }); + const fixture = TestBed.createComponent(MigrationComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.error()).toBe('Only admins can manage the migration token.'); + }); + + it('revokes', () => { + const { component, service } = setup('2026-01-01T00:00:00.000Z'); + component.revoke(); + expect(service.revoke).toHaveBeenCalled(); + expect(component.configuredAt()).toBeUndefined(); + }); +}); diff --git a/src/app/features/admin/settings/migration/migration.component.ts b/src/app/features/admin/settings/migration/migration.component.ts new file mode 100644 index 000000000..7ab7a2314 --- /dev/null +++ b/src/app/features/admin/settings/migration/migration.component.ts @@ -0,0 +1,58 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MigrationService } from '@shared/services/migration.service'; +import { HlmButtonImports } from '@spartan-ng/helm/button'; + +@Component({ + selector: 'll-admin-settings-migration', + templateUrl: './migration.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ClipboardModule, DatePipe, HlmButtonImports], +}) +export class MigrationComponent implements OnInit { + private readonly migration = inject(MigrationService); + private readonly destroyRef = inject(DestroyRef); + + readonly configuredAt = signal(undefined); + /** Shown once after generating; never stored in the app. */ + readonly newToken = signal(undefined); + readonly error = signal(undefined); + + ngOnInit(): void { + this.migration + .status() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: it => this.configuredAt.set(it.createdAt), + // configs/migration is readable by admins only. + error: () => this.error.set('Only admins can manage the migration token.'), + }); + } + + generate(): void { + this.error.set(undefined); + this.migration.generate().subscribe({ + next: it => { + this.newToken.set(it.token); + this.configuredAt.set(it.createdAt); + }, + error: err => this.fail(err), + }); + } + + revoke(): void { + this.error.set(undefined); + this.migration.revoke().subscribe({ next: () => this.configuredAt.set(undefined), error: err => this.fail(err) }); + } + + private fail(err: unknown): void { + const denied = (err as { code?: string } | undefined)?.code === 'functions/permission-denied'; + this.error.set(denied ? 'Only admins can manage the migration token.' : 'The migration token could not be changed.'); + } + + dismissToken(): void { + this.newToken.set(undefined); + } +} diff --git a/src/app/features/admin/settings/settings-routing.module.ts b/src/app/features/admin/settings/settings-routing.module.ts index 6bfb0b67c..561ec24ca 100644 --- a/src/app/features/admin/settings/settings-routing.module.ts +++ b/src/app/features/admin/settings/settings-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { MigrationComponent } from './migration/migration.component'; import { SettingsComponent } from './settings.component'; import { UiComponent } from './ui/ui.component'; @@ -14,6 +15,10 @@ const routes: Routes = [ path: 'ui', component: UiComponent, }, + { + path: 'migration', + component: MigrationComponent, + }, ], }, ]; diff --git a/src/app/features/admin/settings/settings.component.ts b/src/app/features/admin/settings/settings.component.ts index e08530f58..9aed58958 100644 --- a/src/app/features/admin/settings/settings.component.ts +++ b/src/app/features/admin/settings/settings.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { Router, RouterModule } from '@angular/router'; import { provideIcons } from '@ng-icons/core'; -import { lucideLayoutDashboard } from '@ng-icons/lucide'; +import { lucideArrowRightLeft, lucideLayoutDashboard } from '@ng-icons/lucide'; import { HlmIconImports } from '@spartan-ng/helm/icon'; import { HlmTabsImports } from '@spartan-ng/helm/tabs'; @@ -19,6 +19,7 @@ interface TabItem { imports: [RouterModule, HlmTabsImports, HlmIconImports], providers: [ provideIcons({ + lucideArrowRightLeft, lucideLayoutDashboard, }), ], @@ -27,7 +28,10 @@ export class SettingsComponent { private readonly router = inject(Router); activeTab = signal('ui'); - tabItems: TabItem[] = [{ icon: 'lucideLayoutDashboard', label: 'UI', link: 'ui' }]; + tabItems: TabItem[] = [ + { icon: 'lucideLayoutDashboard', label: 'UI', link: 'ui' }, + { icon: 'lucideArrowRightLeft', label: 'Migration', link: 'migration' }, + ]; constructor() { const idx = this.router.url.lastIndexOf('/'); diff --git a/src/app/shared/services/migration.service.ts b/src/app/shared/services/migration.service.ts new file mode 100644 index 000000000..5e37c9c49 --- /dev/null +++ b/src/app/shared/services/migration.service.ts @@ -0,0 +1,26 @@ +import { inject, Injectable } from '@angular/core'; +import { doc, docData, Firestore } from '@angular/fire/firestore'; +import { Functions, httpsCallableData } from '@angular/fire/functions'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +/** The environment's migration token, used by a self-hosted install to import spaces. */ +@Injectable({ providedIn: 'root' }) +export class MigrationService { + private readonly firestore = inject(Firestore); + private readonly functions = inject(Functions); + + status(): Observable<{ createdAt?: string }> { + return docData(doc(this.firestore, 'configs/migration')).pipe( + map(it => ({ createdAt: (it?.['createdAt'] as { toDate?: () => Date } | undefined)?.toDate?.().toISOString() })), + ); + } + + generate(): Observable<{ token: string; createdAt: string }> { + return httpsCallableData(this.functions, 'migrationtoken-generate')(); + } + + revoke(): Observable { + return httpsCallableData(this.functions, 'migrationtoken-revoke')(); + } +}