refactor(admin): standardize management forms

This commit is contained in:
saltbo
2026-06-24 14:50:08 -04:00
parent 5b385faf01
commit e55dae3d2f
68 changed files with 5436 additions and 847 deletions
+1
View File
@@ -17,6 +17,7 @@ Core architecture: clients upload directly to S3-compatible storage via presigne
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, commands, quality gates, migration workflow, deployment
- [docs/architecture.md](docs/architecture.md) — system architecture, tech decisions, platform abstraction
- [docs/design/admin-form-ui.md](docs/design/admin-form-ui.md) — admin form layout, density, required/help/placeholder rules
- [V2_ROADMAP.md](V2_ROADMAP.md) — product positioning, release plan (v2.0v2.9)
- [docs/roadmap/](docs/roadmap/) — per-version technical specs (v2.0.mdv2.9.md)
- [docs/design/spaces-quota-sharing.md](docs/design/spaces-quota-sharing.md) — spaces/quota/sharing design decisions (team billing, allocation, cross-space transfer, no per-item ACL)
+55
View File
@@ -0,0 +1,55 @@
# Admin Form UI
This document defines the UI rules for admin create/edit/configuration forms.
## Surface
- Admin create/edit/configuration forms must live in `AdminFormDrawer`, a dialog, or a dedicated secondary page.
- Do not place management forms directly in primary list/detail page content.
- Drawer actions belong in the drawer footer. Use one horizontal row: secondary action first, primary submit last.
- Do not auto-focus the first input when it creates visual noise. Use `onOpenAutoFocus={(event) => event.preventDefault()}` for configuration drawers where immediate typing is not the primary action.
## Layout
- Use `AdminFormDrawer` for admin drawers.
- Use `bodyClassName="grid auto-rows-min content-start gap-4"` for ordinary vertical forms.
- Use `auto-rows-min content-start` whenever the body uses CSS grid; otherwise grid rows can stretch and create large empty gaps.
- Use `AdminFormField` for text, password, number, textarea, and select-like fields.
- Use `AdminFormLabel` only when a field needs custom composition, such as input suffix controls.
- Use `AdminSwitchField` for switch rows unless a form-specific compact inline layout is required.
- Keep cards out of forms unless the section is a genuinely framed, repeated, or gated sub-surface.
## Field Rules
- Every input must have a label.
- Every input must have a placeholder. Use concise examples, not instructions.
- Required fields must use `required` on `AdminFormField` or `AdminFormLabel`; this shows the required marker and sets `aria-required`.
- Long explanations belong in `help`, not always-visible body text.
- Use visible `description` only when the text is needed while editing and is short enough to not dominate the field.
- Error text stays under the control via `error`.
## Density
- Default field spacing is intentionally compact but readable:
- Field internal spacing: `AdminFormField` default.
- Form item spacing: drawer body `gap-4`.
- Do not use viewport-sized spacing, stretched grid rows, or large section padding in forms.
- Do not over-compress form items below `gap-3` unless the form is a dense table-like editor.
## Switches
- Switches should not appear as oversized cards.
- If a switch enables dependent fields, keep those fields visible and disabled when off unless hiding them materially improves comprehension.
- Put plan badges, such as `ProBadge`, next to the field label.
- Put explanatory text behind `help` unless it is a gate notice or required state message.
## Input Suffixes
- For numeric value + unit controls, prefer a single composed control that visually reads as one input with a suffix selector.
- Do not show a separate preview when it duplicates the selected value and unit.
- Keep disabled suffix controls visually disabled as one group.
## Localization
- Labels, placeholders, help text, descriptions, errors, and success messages must use i18n keys.
- New `admin.storages.*` keys must be added to the corresponding locale test.
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `storages` DROP COLUMN `title`;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -344,6 +344,13 @@
"when": 1782201600733,
"tag": "0049_add-force-path-style",
"breakpoints": true
},
{
"idx": 50,
"version": "6",
"when": 1782320738724,
"tag": "0050_drop-storage-title",
"breakpoints": true
}
]
}
-1
View File
@@ -94,7 +94,6 @@ vi.mock('@aws-sdk/s3-request-presigner', () => ({
function makeStorage(overrides: Partial<Storage> = {}): Storage {
return {
id: 's1',
title: 'Test',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -61,8 +61,8 @@ const STORAGE_ID = 'st-conflict'
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -41,8 +41,8 @@ function commitConflictPlan(db: TestDb, orgId: string, plan: ConflictPlan, userI
async function insertStorage(db: TestDb, id = 'st-1') {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -44,8 +44,8 @@ async function insertStorage(db: TestDb, opts: { id?: string; used?: number } =
const id = opts.id ?? 'st-1'
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test S3', 'test-bucket', 'https://s3.example.com', 'us-east-1',
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'test-bucket', 'https://s3.example.com', 'us-east-1',
'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, ${opts.used ?? 0}, 'active', ${now}, ${now})
`)
return id
@@ -7,7 +7,6 @@ describe('createStorage', () => {
it('sets filePath to empty string regardless of input', async () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -21,7 +20,6 @@ describe('createStorage', () => {
it('sets customHost to empty string when not provided', async () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -35,7 +33,6 @@ describe('createStorage', () => {
it('uses provided customHost when given', async () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -50,7 +47,6 @@ describe('createStorage', () => {
it('sets capacity to 0 when not provided', async () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -64,7 +60,6 @@ describe('createStorage', () => {
it('uses provided capacity when given', async () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -78,7 +73,6 @@ describe('createStorage', () => {
it('initialises used to 0 and status to active', async () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).create({
title: 'My Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'auto',
@@ -93,7 +87,6 @@ describe('createStorage', () => {
it('persists the created row to the database', async () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'Persisted',
bucket: 'my-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -103,14 +96,13 @@ describe('createStorage', () => {
})
const fetched = await createStorageRepo(db).get(created.id)
expect(fetched?.id).toBe(created.id)
expect(fetched?.title).toBe('Persisted')
expect(fetched?.bucket).toBe('my-bucket')
})
})
describe('updateStorage', () => {
async function seed(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
return createStorageRepo(db).create({
title: 'Original',
bucket: 'original-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -123,14 +115,14 @@ describe('updateStorage', () => {
it('returns null when storage does not exist', async () => {
const { db } = await createTestApp()
const result = await createStorageRepo(db).update('nonexistent', { title: 'New' })
const result = await createStorageRepo(db).update('nonexistent', { bucket: 'new-bucket' })
expect(result).toBeNull()
})
it('keeps existing values for fields not included in update', async () => {
const { db } = await createTestApp()
const created = await seed(db)
const updated = await createStorageRepo(db).update(created.id, { title: 'Changed' })
const updated = await createStorageRepo(db).update(created.id, {})
expect(updated?.bucket).toBe('original-bucket')
expect(updated?.region).toBe('us-east-1')
expect(updated?.accessKey).toBe('AKID')
@@ -143,7 +135,6 @@ describe('updateStorage', () => {
const { db } = await createTestApp()
const created = await seed(db)
const updated = await createStorageRepo(db).update(created.id, {
title: 'Updated',
bucket: 'new-bucket',
endpoint: 'https://r2.example.com',
region: 'auto',
@@ -153,7 +144,6 @@ describe('updateStorage', () => {
capacity: 1000,
status: 'disabled',
})
expect(updated?.title).toBe('Updated')
expect(updated?.bucket).toBe('new-bucket')
expect(updated?.endpoint).toBe('https://r2.example.com')
expect(updated?.region).toBe('auto')
@@ -169,7 +159,7 @@ describe('updateStorage', () => {
const created = await seed(db)
const updated = await createStorageRepo(db).update(created.id, { status: 'disabled' })
expect(updated?.status).toBe('disabled')
expect(updated?.title).toBe('Original')
expect(updated?.bucket).toBe('original-bucket')
})
it('updates the updatedAt timestamp', async () => {
@@ -177,7 +167,7 @@ describe('updateStorage', () => {
const created = await seed(db)
const before = created.updatedAt.getTime()
await new Promise((r) => setTimeout(r, 10))
const updated = await createStorageRepo(db).update(created.id, { title: 'New Title' })
const updated = await createStorageRepo(db).update(created.id, { bucket: 'new-bucket' })
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(before)
})
})
@@ -192,7 +182,6 @@ describe('listStorages', () => {
it('returns all storages ordered by createdAt ascending', async () => {
const { db } = await createTestApp()
await createStorageRepo(db).create({
title: 'First',
bucket: 'b1',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -201,7 +190,6 @@ describe('listStorages', () => {
capacity: 0,
})
await createStorageRepo(db).create({
title: 'Second',
bucket: 'b2',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -225,7 +213,6 @@ describe('getStorage', () => {
it('returns the storage when it exists', async () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'Findable',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -241,11 +228,10 @@ describe('getStorage', () => {
describe('selectStorage', () => {
async function seedActive(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
opts: { capacity?: number; used?: number; status?: string; title?: string } = {},
opts: { capacity?: number; used?: number; status?: string; bucket?: string } = {},
) {
const created = await createStorageRepo(db).create({
title: opts.title ?? 'Seed',
bucket: 'b',
bucket: opts.bucket ?? 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
accessKey: 'K',
@@ -269,8 +255,8 @@ describe('selectStorage', () => {
it('returns the requested active storage with capacity even when it is not oldest', async () => {
const { db } = await createTestApp()
const first = await seedActive(db, { title: 'First' })
const second = await seedActive(db, { title: 'Second' })
const first = await seedActive(db, { bucket: 'first' })
const second = await seedActive(db, { bucket: 'second' })
const auto = await createStorageRepo(db).select()
const targeted = await createStorageRepo(db).select(second?.id)
@@ -307,7 +293,6 @@ describe('deleteStorage', () => {
it('deletes a storage that is not referenced by any matter', async () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'Deletable',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -323,7 +308,6 @@ describe('deleteStorage', () => {
it('returns in_use when matters reference the storage', async () => {
const { db } = await createTestApp()
const created = await createStorageRepo(db).create({
title: 'In Use',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
-2
View File
@@ -31,7 +31,6 @@ export function createStorageRepo(db: Database): StorageRepo {
const now = new Date()
const row: StorageRow = {
id: nanoid(),
title: input.title,
bucket: input.bucket,
endpoint: input.endpoint,
region: input.region ?? 'auto',
@@ -64,7 +63,6 @@ export function createStorageRepo(db: Database): StorageRepo {
const now = new Date()
const updated = {
title: input.title ?? existing.title,
bucket: input.bucket ?? existing.bucket,
endpoint: input.endpoint ?? existing.endpoint,
region: input.region ?? existing.region,
-1
View File
@@ -57,7 +57,6 @@ export const webdavLocks = sqliteTable(
export const storages = sqliteTable('storages', {
id: text('id').primaryKey(),
title: text('title').notNull(),
bucket: text('bucket').notNull(),
endpoint: text('endpoint').notNull(),
region: text('region').notNull().default('auto'),
@@ -350,8 +350,8 @@ describe('background jobs API', () => {
async function seedStorage(db: TestDb): Promise<void> {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('route-storage', 'Route Storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('route-storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -76,12 +76,12 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
'remote-download-storage', 'Remote Download Storage', 'test-bucket',
'remote-download-storage', 'test-bucket',
'https://s3.example.com', 'auto', 'test-access-key', 'test-secret-key',
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
)
@@ -998,7 +998,6 @@ describe('DELETE /api/image-hosting/config', () => {
const storageId = nanoid()
await db.insert(schema.storages).values({
id: storageId,
title: 'Test Storage',
bucket: 'test',
endpoint: 'https://s3.example.com',
region: 'auto',
@@ -14,7 +14,6 @@ beforeEach(() => {
const validStorage = {
id: 'st-ihost-1',
title: 'Test S3',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -28,8 +27,8 @@ type TestAuth = Awaited<ReturnType<typeof createTestApp>>['auth']
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+2 -2
View File
@@ -117,8 +117,8 @@ async function signUp(app: ReturnType<typeof createApp>, db: Awaited<ReturnType<
async function insertStorage(db: Awaited<ReturnType<typeof buildAppWithDb>>['db'], id: string) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+18 -20
View File
@@ -89,7 +89,6 @@ afterEach(() => {
const validStorage = {
id: 'st-1',
title: 'Test S3',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -106,12 +105,12 @@ async function insertStorage(
const id = opts.id ?? validStorage.id
await db.run(sql`
INSERT INTO storages (
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
${id}, ${validStorage.title}, ${validStorage.bucket},
${id}, ${validStorage.bucket},
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey},
'', '', ${opts.capacity ?? 0}, ${opts.used ?? 0}, ${opts.status ?? 'active'}, ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
@@ -906,8 +905,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
@@ -929,8 +928,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
@@ -953,8 +952,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
await createMatter(db, {
@@ -1004,8 +1003,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
orgId: 'org-1',
@@ -1023,8 +1022,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const matter = await createMatter(db, {
orgId: 'org-1',
@@ -1053,8 +1052,8 @@ describe('Matter service', () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const source = await createMatter(db, {
orgId: 'org-1',
@@ -1494,7 +1493,6 @@ describe('POST /api/objects/:id/transfers', () => {
describe('Objects API — quota enforcement', () => {
const validStorage = {
id: 'st-quota',
title: 'Quota S3',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -1505,8 +1503,8 @@ describe('Objects API — quota enforcement', () => {
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db'], used = 0) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.bucket},
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey},
${validStorage.secretKey}, '', '', 0, ${used}, 'active', ${now}, ${now})
`)
@@ -2133,12 +2131,12 @@ describe('object multipart upload API with S3-compatible storage', () => {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
'multipart-live-storage', 'Multipart Live Storage', 'test-bucket',
'multipart-live-storage', 'test-bucket',
${endpoint}, 'auto', 'test-access-key', 'test-secret-key',
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
)
+2 -2
View File
@@ -37,8 +37,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+4 -4
View File
@@ -21,8 +21,8 @@ beforeEach(() => {
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -601,8 +601,8 @@ describe('GET /r/:token — two-org isolation', () => {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
await insertImageHosting(db, orgId, { id: 'ih-iso1', token: 'ih_isolationtest' })
+2 -2
View File
@@ -64,8 +64,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+4 -5
View File
@@ -14,7 +14,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
const validStorage = {
id: 'st-share-test',
title: 'Test S3',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -25,8 +24,8 @@ const validStorage = {
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -1059,8 +1058,8 @@ describe('Public share routes', () => {
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -17,7 +17,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
const validStorage = {
id: 'st-audit-test',
title: 'Audit Test S3',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -28,8 +27,8 @@ const validStorage = {
async function insertStorage(db: TestDb, id = validStorage.id) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -394,7 +393,6 @@ describe('Storage audit events', () => {
method: 'POST',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'New Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -407,7 +405,7 @@ describe('Storage audit events', () => {
const evt = await getLatestActivity(db, 'storage_create')
expect(evt).toBeDefined()
expect(evt?.targetType).toBe('storage')
expect(evt?.targetName).toBe('New Storage')
expect(evt?.targetName).toBe('my-bucket')
// Must NOT store secret keys or access keys in metadata
assertNoSecrets(evt?.metadata ?? null)
})
@@ -421,7 +419,6 @@ describe('Storage audit events', () => {
method: 'POST',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Original Storage',
bucket: 'my-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -434,14 +431,14 @@ describe('Storage audit events', () => {
const updateRes = await app.request(`/api/site/storages/${storageId}`, {
method: 'PUT',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Updated Storage' }),
body: JSON.stringify({ bucket: 'updated-bucket' }),
})
expect(updateRes.status).toBe(200)
const evt = await getLatestActivity(db, 'storage_update')
expect(evt).toBeDefined()
expect(evt?.targetType).toBe('storage')
expect(evt?.targetName).toBe('Updated Storage')
expect(evt?.targetName).toBe('updated-bucket')
assertNoSecrets(evt?.metadata ?? null)
})
@@ -454,7 +451,6 @@ describe('Storage audit events', () => {
method: 'POST',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Deletable Storage',
bucket: 'del-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -473,7 +469,7 @@ describe('Storage audit events', () => {
const evt = await getLatestActivity(db, 'storage_delete')
expect(evt).toBeDefined()
expect(evt?.targetType).toBe('storage')
expect(evt?.targetName).toBe('Deletable Storage')
expect(evt?.targetName).toBe('del-bucket')
assertNoSecrets(evt?.metadata ?? null)
})
})
+2 -6
View File
@@ -34,7 +34,6 @@ async function adminHeaders(app: ReturnType<typeof buildApp>) {
}
const validStorage = {
title: 'CF Test S3',
bucket: 'cf-test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -66,7 +65,6 @@ describe('[CF] Admin Storages API', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
...validStorage,
title: `CF Test S3 ${Date.now()}`,
bucket: `cf-test-bucket-${Date.now()}`,
}),
})
@@ -96,7 +94,6 @@ describe('[CF] Admin Storages API', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
...validStorage,
title: `CF Storage ${Date.now()}-${i}`,
bucket: `cf-bucket-${Date.now()}-${i}`,
}),
})
@@ -138,18 +135,17 @@ describe('[CF] Admin Storages API', () => {
const platform = createCloudflarePlatform(env)
const created = await createStorageRepo(platform.db).create({
...validStorage,
title: `CF Update ${Date.now()}`,
bucket: `cf-update-${Date.now()}`,
})
const res = await app.request(`/api/site/storages/${created.id}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Updated CF S3' }),
body: JSON.stringify({ bucket: 'updated-cf-bucket' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.title).toBe('Updated CF S3')
expect(body.bucket).toBe('updated-cf-bucket')
})
it('PUT /api/site/storages/:id/egress-billing enforces quota_store for enabling', async () => {
+9 -12
View File
@@ -5,7 +5,6 @@ import { createStorageRepo } from '../../adapters/repos/storage.js'
import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../../test/setup.js'
const validStorage = {
title: 'Test S3',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
@@ -55,7 +54,6 @@ describe('Admin Storages API', () => {
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.title).toBe('Test S3')
expect(body.bucket).toBe('test-bucket')
expect(body.status).toBe('active')
expect(body.capacity).toBe(0)
@@ -90,7 +88,7 @@ describe('Admin Storages API', () => {
const res = await app.request('/api/site/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...validStorage, title: `Storage ${i}`, bucket: `bucket-${i}` }),
body: JSON.stringify({ ...validStorage, bucket: `bucket-${i}` }),
})
expect(res.status).toBe(201)
}
@@ -98,7 +96,7 @@ describe('Admin Storages API', () => {
const res = await app.request('/api/site/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...validStorage, title: 'Storage overflow', bucket: 'bucket-overflow' }),
body: JSON.stringify({ ...validStorage, bucket: 'bucket-overflow' }),
})
expect(res.status).toBe(402)
@@ -126,7 +124,7 @@ describe('Admin Storages API', () => {
const body = (await res.json()) as { items: Array<Record<string, unknown>>; total: number }
expect(body.total).toBe(1)
expect(body.items).toHaveLength(1)
expect(body.items[0].title).toBe('Test S3')
expect(body.items[0].bucket).toBe('test-bucket')
expect(body.items[0].forcePathStyle).toBe(true)
})
@@ -145,7 +143,7 @@ describe('Admin Storages API', () => {
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.id).toBe(created.id)
expect(body.title).toBe('Test S3')
expect(body.bucket).toBe('test-bucket')
})
it('GET /:id returns 404 for missing storage', async () => {
@@ -169,11 +167,11 @@ describe('Admin Storages API', () => {
const res = await app.request(`/api/site/storages/${created.id}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Updated S3', status: 'disabled', forcePathStyle: false }),
body: JSON.stringify({ bucket: 'updated-bucket', status: 'disabled', forcePathStyle: false }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.title).toBe('Updated S3')
expect(body.bucket).toBe('updated-bucket')
expect(body.status).toBe('disabled')
expect(body.forcePathStyle).toBe(false)
})
@@ -184,7 +182,7 @@ describe('Admin Storages API', () => {
const res = await app.request('/api/site/storages/nonexistent', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Nope' }),
body: JSON.stringify({ bucket: 'nope' }),
})
expect(res.status).toBe(404)
})
@@ -329,10 +327,9 @@ async function insertStorage(
const capacity = opts.capacity ?? 0
const used = opts.used ?? 0
const status = opts.status ?? 'active'
const title = `Storage ${opts.id}`
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${opts.id}, ${title}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${opts.id}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
`)
}
-1
View File
@@ -19,7 +19,6 @@ import { errorResponse, jsonBody, jsonContent } from '../openapi'
const storageSchema = z
.object({
id: z.string(),
title: z.string(),
bucket: z.string(),
endpoint: z.string(),
region: z.string(),
@@ -42,12 +42,12 @@ async function insertStorage(db: Database) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
${STORAGE_ID}, 'Cloud Traffic S3', 'test-bucket', 'https://s3.amazonaws.com',
${STORAGE_ID}, 'test-bucket', 'https://s3.amazonaws.com',
'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', true, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
`)
+2 -4
View File
@@ -13,7 +13,6 @@ type TestApp = Awaited<ReturnType<typeof createTestApp>>
const storage = {
id: 'dav-storage',
title: 'DAV Storage',
bucket: 'dav-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -47,8 +46,8 @@ function streamBody(text: string): ReadableStream {
async function seedStorage(db: TestApp['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${storage.id}, ${storage.title}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${storage.id}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -2042,7 +2041,6 @@ describe('WebDAV API', () => {
describe('WebDAV over real HTTP (npm client)', () => {
const e2eStorage = {
id: 'webdav-e2e-storage',
title: 'WebDAV E2E Storage',
bucket: 'webdav-e2e-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -33,8 +33,8 @@ async function signUpAndGetOrgId(app: ReturnType<typeof createApp>, db: TestDb)
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF Domain S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -21,8 +21,8 @@ async function getOrgId(db: TestDb): Promise<string> {
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
-1
View File
@@ -159,7 +159,6 @@ const APP_SCHEMA_SQL = `
CREATE INDEX IF NOT EXISTS webdav_locks_expires_idx ON webdav_locks(expires_at);
CREATE TABLE IF NOT EXISTS storages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
bucket TEXT NOT NULL,
endpoint TEXT NOT NULL,
region TEXT NOT NULL DEFAULT 'auto',
+2 -2
View File
@@ -830,8 +830,8 @@ describe('archive processing', () => {
async function seedStorage(db: TestDb): Promise<void> {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Archive Storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
+1 -1
View File
@@ -27,7 +27,7 @@ import {
// Fakes for the ports the image-hosting usecase touches. Each test overrides the
// handful of methods it exercises; the rest throw so an unexpected call is loud.
const sampleStorage = { id: 'st-1', title: 'S3' } as StorageRecord
const sampleStorage = { id: 'st-1', bucket: 'bucket' } as StorageRecord
const sampleConfig: ImageHostingConfigRecord = {
orgId: 'o1',
+2 -2
View File
@@ -39,8 +39,8 @@ const saveShareToDrive = (db: Database, input: SaveShareInput) => saveShareToDri
async function seedStorage(db: ReturnType<typeof buildDb>, id: string) {
await db.run(
`INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('${id}', 'CF Test S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIA...', 'secret...', '', '', 0, 0, 'active', ${Date.now()}, ${Date.now()})`,
`INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('${id}', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIA...', 'secret...', '', '', 0, 0, 'active', ${Date.now()}, ${Date.now()})`,
)
}
+4 -4
View File
@@ -53,8 +53,8 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
async function insertStorage(db: TestDb, id = STORAGE_ID) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI', '', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${id}, 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
@@ -824,8 +824,8 @@ describe('trash purge', () => {
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('st-1', 'Test S3', 'b', 'https://s3.example.com', 'us-east-1', 'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('st-1', 'b', 'https://s3.example.com', 'us-east-1', 'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
}
-1
View File
@@ -50,7 +50,6 @@ vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn() }))
const storage = {
id: 'st-1',
title: 'S3',
egressCreditBillingEnabled: false,
egressCreditUnitBytes: 0,
egressCreditPerUnit: 0,
+6 -7
View File
@@ -27,10 +27,9 @@ const BUSINESS: BindingState = { bound: true, active: true, edition: 'business'
const edition = (state: BindingState) => vi.mocked(loadBindingState).mockResolvedValue(state)
const sampleStorage = { id: 'st-1', title: 'My S3' } as StorageRecord
const sampleStorage = { id: 'st-1', bucket: 'b' } as StorageRecord
const validInput: CreateStorageInput = {
title: 'My S3',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -152,16 +151,16 @@ describe('storage usecase', () => {
edition(COMMUNITY)
const update = vi.fn(async () => sampleStorage)
const { deps, record } = makeDeps({ update })
const out = await updateStorage(deps, { userId: 'u1', orgId: 'o1', id: 'st-1', input: { title: 'New' } })
const out = await updateStorage(deps, { userId: 'u1', orgId: 'o1', id: 'st-1', input: { bucket: 'new-b' } })
expect(out).toEqual({ ok: true, storage: sampleStorage })
expect(update).toHaveBeenCalledWith('st-1', { title: 'New' })
expect(update).toHaveBeenCalledWith('st-1', { bucket: 'new-b' })
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'storage_update', targetId: 'st-1' }))
})
it('returns not_found for a missing storage', async () => {
edition(COMMUNITY)
const { deps, record } = makeDeps({ update: async () => null })
const out = await updateStorage(deps, { userId: 'u1', orgId: 'o1', id: 'x', input: { title: 'New' } })
const out = await updateStorage(deps, { userId: 'u1', orgId: 'o1', id: 'x', input: { bucket: 'new-b' } })
expect(out.ok).toBe(false)
if (!out.ok) {
expect(out.error).toBeInstanceOf(AppError)
@@ -271,13 +270,13 @@ describe('storage usecase', () => {
})
describe('deleteStorage', () => {
it('deletes and records activity with the storage name', async () => {
it('deletes and records activity with the storage bucket', async () => {
const del = vi.fn(async () => 'ok' as const)
const { deps, record } = makeDeps({ get: async () => sampleStorage, delete: del })
const out = await deleteStorage(deps, { userId: 'u1', orgId: 'o1', id: 'st-1' })
expect(out).toEqual({ ok: true })
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'storage_delete', targetId: 'st-1', targetName: 'My S3' }),
expect.objectContaining({ action: 'storage_delete', targetId: 'st-1', targetName: 'b' }),
)
})
+4 -4
View File
@@ -91,7 +91,7 @@ export async function createStorage(
action: 'storage_create',
targetType: 'storage',
targetId: storage.id,
targetName: storage.title,
targetName: storage.bucket,
})
return { ok: true, storage }
}
@@ -116,7 +116,7 @@ export async function updateStorage(
action: 'storage_update',
targetType: 'storage',
targetId: storage.id,
targetName: storage.title,
targetName: storage.bucket,
})
return { ok: true, storage }
}
@@ -143,7 +143,7 @@ export async function updateStorageEgressBilling(
action: 'storage_update',
targetType: 'storage',
targetId: storage.id,
targetName: storage.title,
targetName: storage.bucket,
})
return { ok: true, storage }
}
@@ -163,7 +163,7 @@ export async function deleteStorage(
action: 'storage_delete',
targetType: 'storage',
targetId: id,
targetName: existing?.title ?? id,
targetName: existing?.bucket ?? id,
})
return { ok: true }
}
-1
View File
@@ -47,7 +47,6 @@ vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn() }))
const storage = {
id: 'st-1',
title: 'S3',
egressCreditBillingEnabled: false,
egressCreditUnitBytes: 0,
egressCreditPerUnit: 0,
-1
View File
@@ -45,7 +45,6 @@ describe('signUpSchema', () => {
describe('createStorageSchema', () => {
const valid = {
title: 'My S3',
bucket: 'my-bucket',
endpoint: 'https://s3.amazonaws.com',
accessKey: 'AK',
-2
View File
@@ -1,7 +1,6 @@
import { z } from 'zod'
export const createStorageSchema = z.object({
title: z.string().min(1),
bucket: z.string().min(1),
endpoint: z.string().url(),
region: z.string().default('auto'),
@@ -16,7 +15,6 @@ export const createStorageSchema = z.object({
})
export const updateStorageSchema = z.object({
title: z.string().min(1).optional(),
bucket: z.string().min(1).optional(),
endpoint: z.string().url().optional(),
region: z.string().optional(),
-1
View File
@@ -26,7 +26,6 @@ export interface StorageObject {
export interface Storage {
id: string
title: string
bucket: string
endpoint: string
region: string
@@ -1,7 +1,7 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Input } from '@/components/ui/input'
import { AdminFormDrawer, AdminFormField } from './admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminSwitchField } from './admin-form-drawer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@@ -34,7 +34,12 @@ describe('AdminFormDrawer', () => {
expect(screen.getByText('Configure storage')).toBeTruthy()
expect(screen.getByRole('form', { name: 'Storage form' })).toBeTruthy()
expect(screen.getByText('Drawer body')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy()
const saveButton = screen.getByRole('button', { name: 'Save' })
const footer = saveButton.parentElement
expect(saveButton).toBeTruthy()
expect(footer?.getAttribute('data-slot')).toBe('sheet-footer')
expect(footer?.className).toContain('flex-row')
expect(footer?.className).toContain('justify-end')
})
})
@@ -66,4 +71,40 @@ describe('AdminFormField', () => {
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(input.getAttribute('aria-describedby')).toBe('external-help field-id-description field-id-error')
})
it('renders required and help affordances without changing label association', () => {
render(
<AdminFormField id="bucket" label="Bucket" required help="Use the exact provider bucket name.">
<Input />
</AdminFormField>,
)
const input = screen.getByLabelText('Bucket')
expect(input.getAttribute('aria-required')).toBe('true')
expect(screen.getByText('*')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Help' })).toBeTruthy()
})
})
describe('AdminSwitchField', () => {
it('renders a labeled switch with description in a consistent form row', () => {
render(<AdminSwitchField id="enabled" label="Enabled" description="Allow this feature" checked />)
const switchControl = screen.getByRole('switch', { name: 'Enabled' })
const field = switchControl.parentElement
expect(switchControl.getAttribute('id')).toBe('enabled')
expect(switchControl.getAttribute('aria-describedby')).toBe('enabled-description')
expect(screen.getByText('Allow this feature')).toBeTruthy()
expect(field?.className).toContain('rounded-md')
expect(field?.className).toContain('border')
})
it('marks a switch field as required when requested', () => {
render(<AdminSwitchField id="enabled" label="Enabled" required checked />)
const switchControl = screen.getByRole('switch', { name: 'Enabled' })
expect(switchControl.getAttribute('aria-required')).toBe('true')
expect(screen.getByText('*')).toBeTruthy()
})
})
+147 -6
View File
@@ -1,3 +1,4 @@
import { CircleHelp } from 'lucide-react'
import {
Children,
type ComponentProps,
@@ -9,6 +10,8 @@ import {
} from 'react'
import { Label } from '@/components/ui/label'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
const drawerWidths = {
@@ -29,6 +32,7 @@ interface AdminFormDrawerProps extends ComponentProps<typeof Sheet> {
bodyClassName?: string
footerClassName?: string
formProps?: ComponentProps<'form'>
onOpenAutoFocus?: ComponentProps<typeof SheetContent>['onOpenAutoFocus']
}
export function AdminFormDrawer({
@@ -41,14 +45,30 @@ export function AdminFormDrawer({
bodyClassName,
footerClassName,
formProps,
onOpenAutoFocus,
...sheetProps
}: AdminFormDrawerProps) {
const handleOpenAutoFocus: ComponentProps<typeof SheetContent>['onOpenAutoFocus'] = (event) => {
if (onOpenAutoFocus) {
onOpenAutoFocus(event)
return
}
event.preventDefault()
}
const body = <div className={cn('min-h-0 flex-1 overflow-y-auto px-4', bodyClassName)}>{children}</div>
const footerContent = footer ? <SheetFooter className={footerClassName}>{footer}</SheetFooter> : null
const footerContent = footer ? (
<SheetFooter className={cn('shrink-0 flex-row items-center justify-end border-t bg-background', footerClassName)}>
{footer}
</SheetFooter>
) : null
return (
<Sheet {...sheetProps}>
<SheetContent side="right" className={cn('overflow-hidden', drawerWidths[width], className)}>
<SheetContent
side="right"
className={cn('overflow-hidden', drawerWidths[width], className)}
onOpenAutoFocus={handleOpenAutoFocus}
>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description && <SheetDescription>{description}</SheetDescription>}
@@ -73,6 +93,7 @@ type FieldControlProps = {
id?: string
'aria-invalid'?: boolean
'aria-describedby'?: string
'aria-required'?: boolean
}
type AdminFormFieldChildren = ReactNode | ((controlProps: FieldControlProps) => ReactNode)
@@ -80,13 +101,36 @@ type AdminFormFieldChildren = ReactNode | ((controlProps: FieldControlProps) =>
interface AdminFormFieldProps {
label: ReactNode
description?: ReactNode
help?: ReactNode
required?: boolean
error?: ReactNode
id?: string
className?: string
children: AdminFormFieldChildren
}
export function AdminFormField({ label, description, error, id, className, children }: AdminFormFieldProps) {
interface AdminSwitchFieldProps
extends Omit<ComponentProps<typeof Switch>, 'id' | 'className' | 'aria-describedby' | 'aria-invalid'> {
id: string
label: ReactNode
description?: ReactNode
help?: ReactNode
required?: boolean
error?: ReactNode
className?: string
switchClassName?: string
}
export function AdminFormField({
label,
description,
help,
required,
error,
id,
className,
children,
}: AdminFormFieldProps) {
const generatedId = useId()
const fieldId = id ?? generatedId
const descriptionId = description ? `${fieldId}-description` : undefined
@@ -96,6 +140,7 @@ export function AdminFormField({ label, description, error, id, className, child
id: fieldId,
'aria-invalid': error ? true : undefined,
'aria-describedby': describedBy,
'aria-required': required ? true : undefined,
}
const renderedChildren = typeof children === 'function' ? children(controlProps) : children
const child =
@@ -106,12 +151,14 @@ export function AdminFormField({ label, description, error, id, className, child
const controlId = child && canDecorateChild ? (child.props.id ?? fieldId) : fieldId
const control =
child && canDecorateChild
? cloneElement(child, decorateControlProps(child, fieldId, error, describedBy))
? cloneElement(child, decorateControlProps(child, fieldId, error, describedBy, required))
: renderedChildren
return (
<div className={cn('space-y-1.5', className)} data-invalid={error ? true : undefined}>
<Label htmlFor={controlId}>{label}</Label>
<div className={cn('space-y-1', className)} data-invalid={error ? true : undefined}>
<AdminFormLabel htmlFor={controlId} required={required} help={help}>
{label}
</AdminFormLabel>
{description && (
<p id={descriptionId} className="text-xs text-muted-foreground">
{description}
@@ -127,6 +174,98 @@ export function AdminFormField({ label, description, error, id, className, child
)
}
export function AdminSwitchField({
id,
label,
description,
help,
required,
error,
className,
switchClassName,
...switchProps
}: AdminSwitchFieldProps) {
const descriptionId = description ? `${id}-description` : undefined
const errorId = error ? `${id}-error` : undefined
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
return (
<div
className={cn('flex min-h-11 items-start justify-between gap-4 rounded-md border bg-background p-3', className)}
data-invalid={error ? true : undefined}
>
<div className="min-w-0 space-y-0.5">
<AdminFormLabel htmlFor={id} className="leading-5" required={required} help={help}>
{label}
</AdminFormLabel>
{description && (
<p id={descriptionId} className="text-xs leading-5 text-muted-foreground">
{description}
</p>
)}
{error && (
<p id={errorId} className="text-xs leading-5 text-destructive">
{error}
</p>
)}
</div>
<Switch
{...switchProps}
id={id}
className={cn('mt-0.5', switchClassName)}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
aria-required={required ? true : undefined}
/>
</div>
)
}
export function AdminFormLabel({
htmlFor,
children,
required,
help,
className,
}: {
htmlFor: string
children: ReactNode
required?: boolean
help?: ReactNode
className?: string
}) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<Label htmlFor={htmlFor} className={className}>
{children}
</Label>
{required && (
<span aria-hidden="true" className="text-sm leading-none text-destructive">
*
</span>
)}
{help && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Help"
className="inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<CircleHelp className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72">
{help}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
}
function canDecorateControl(child: ReactElement) {
return typeof child.type !== 'string' || !['div', 'span', 'fieldset'].includes(child.type)
}
@@ -136,6 +275,7 @@ function decorateControlProps(
fieldId: string,
error: ReactNode,
describedBy: string | undefined,
required: boolean | undefined,
): FieldControlProps {
const existingDescribedBy = child.props['aria-describedby']
const mergedDescribedBy = [existingDescribedBy, describedBy].filter(Boolean).join(' ') || undefined
@@ -144,5 +284,6 @@ function decorateControlProps(
id: child.props.id ?? fieldId,
'aria-invalid': error ? true : child.props['aria-invalid'],
'aria-describedby': mergedDescribedBy,
'aria-required': required ? true : child.props['aria-required'],
}
}
-2
View File
@@ -8,7 +8,6 @@ import {
Info,
KeyRound,
LayoutDashboard,
Mail,
Megaphone,
Settings,
ShieldCheck,
@@ -37,7 +36,6 @@ const adminNavItems = [
{ titleKey: 'admin.nav.storages', url: '/admin/storages', icon: Database },
{ titleKey: 'admin.nav.downloaders', url: '/admin/downloaders', icon: HardDriveDownload },
{ titleKey: 'admin.nav.auth', url: '/admin/settings/oauth', icon: KeyRound },
{ titleKey: 'admin.nav.email', url: '/admin/settings/email', icon: Mail },
{ titleKey: 'admin.nav.settings', url: '/admin/settings', icon: Settings },
{ titleKey: 'admin.nav.announcement', url: '/admin/announcement', icon: Megaphone },
{ titleKey: 'admin.nav.audit', url: '/admin/audit', icon: ShieldCheck },
@@ -1,9 +1,8 @@
import { lazy, Suspense, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminSwitchField } from '@/components/admin/admin-form-drawer'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import type { Announcement, AnnouncementInput } from '@/lib/api'
const AnnouncementMarkdownEditor = lazy(() =>
@@ -72,11 +71,12 @@ export function AnnouncementFormDialog({
<Input value={title} onChange={(event) => setTitle(event.target.value)} required />
</AdminFormField>
<AdminFormField id="announcement-pinned" label={t('admin.announcement.fieldPinned')}>
<div className="flex h-9 items-center">
<Switch id="announcement-pinned" checked={pinned} onCheckedChange={setPinned} />
</div>
</AdminFormField>
<AdminSwitchField
id="announcement-pinned"
label={t('admin.announcement.fieldPinned')}
checked={pinned}
onCheckedChange={setPinned}
/>
<AdminFormField id="announcement-body" label={t('admin.announcement.fieldBody')}>
<div>
+61 -50
View File
@@ -11,12 +11,12 @@ import { Eye, ImageUp, Palette, RotateCcw, Upload } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AdminFormDrawer } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminFormLabel } from '@/components/admin/admin-form-drawer'
import { ThemeColorInput, ThemePreview } from '@/components/admin/branding-theme-preview'
import { brandingQueryKey } from '@/components/branding/BrandingProvider'
import { ProBadge } from '@/components/ProBadge'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
@@ -25,7 +25,6 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useEntitlement } from '@/hooks/useEntitlement'
import { getBranding, resetBrandingField, saveBranding } from '@/lib/api'
@@ -148,8 +147,10 @@ function FileUploadField({
const uploadLabel = previewUrl ? replaceLabel : emptyLabel
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<div className="flex flex-col gap-1">
<AdminFormLabel htmlFor={id} help={hint}>
{label}
</AdminFormLabel>
<input
ref={inputRef}
id={id}
@@ -330,46 +331,47 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
return (
<>
<Card className="border-border/60">
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-border/60 bg-primary/10 p-2 text-primary">
<Palette className="size-5" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<CardTitle>{t('admin.settings.branding.assetsTitle')}</CardTitle>
<ProBadge tooltip={t('admin.settings.proLockedWhiteLabel')} />
</div>
<CardDescription>{t('admin.settings.branding.assetsDescription')}</CardDescription>
</div>
<Card data-settings-row className="rounded-lg border-border/70 py-0 shadow-xs">
<CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted text-muted-foreground">
<Palette className="size-4" />
</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-sm leading-5">{t('admin.settings.branding.assetsTitle')}</CardTitle>
<ProBadge tooltip={t('admin.settings.proLockedWhiteLabel')} />
</div>
<CardDescription className="max-w-2xl leading-5">
{t('admin.settings.branding.assetsDescription')}
</CardDescription>
<p className="text-muted-foreground text-sm">
{disabled
? t('admin.settings.branding.lockedMessage')
: t(`admin.settings.branding.themePresets.${initial.theme.preset}`)}
</p>
</div>
<CardAction className="flex flex-wrap justify-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Eye className="mr-2 size-4" />
{t('admin.settings.branding.preview')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t('admin.settings.branding.preview')}</DialogTitle>
<DialogDescription>{t('admin.settings.branding.previewHint')}</DialogDescription>
</DialogHeader>
<ThemePreview values={previewTheme} logoUrl={state.previewLogoUrl} />
</DialogContent>
</Dialog>
<Button type="button" size="sm" onClick={() => setDrawerOpen(true)}>
{t('common.edit')}
</Button>
</CardAction>
</div>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>{disabled ? t('admin.settings.branding.lockedMessage') : t('admin.settings.branding.themeDescription')}</p>
<p>{t(`admin.settings.branding.themePresets.${initial.theme.preset}`)}</p>
<div className="flex shrink-0 items-center justify-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Eye className="mr-2 size-4" />
{t('admin.settings.branding.preview')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t('admin.settings.branding.preview')}</DialogTitle>
<DialogDescription>{t('admin.settings.branding.previewHint')}</DialogDescription>
</DialogHeader>
<ThemePreview values={previewTheme} logoUrl={state.previewLogoUrl} />
</DialogContent>
</Dialog>
<Button type="button" size="sm" variant="outline" onClick={() => setDrawerOpen(true)}>
{t('common.edit')}
</Button>
</div>
</CardContent>
</Card>
@@ -382,7 +384,7 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
width="extra-wide"
title={t('admin.settings.branding.assetsTitle')}
description={t('admin.settings.branding.assetsDescription')}
bodyClassName="grid gap-5"
bodyClassName="grid auto-rows-min content-start gap-4"
footer={
<>
<Button
@@ -404,8 +406,8 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
</>
}
>
<div className={cn('flex flex-col gap-5', disabled && 'opacity-60')}>
<div className="grid gap-2 xl:grid-cols-2">
<div className={cn('grid auto-rows-min gap-4', disabled && 'opacity-60')}>
<div className="grid gap-4 xl:grid-cols-2">
<FileUploadField
id="logo-upload"
label={t('admin.settings.branding.logo')}
@@ -434,8 +436,12 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
/>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="theme-mode">{t('admin.settings.branding.themeMode')}</Label>
<AdminFormField
id="theme-mode"
label={t('admin.settings.branding.themeMode')}
help={t('admin.settings.branding.themeDescription')}
required
>
<Select
value={themeSourceValue}
disabled={disabled}
@@ -449,7 +455,7 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
}}
>
<SelectTrigger id="theme-mode" className="w-full">
<SelectValue />
<SelectValue placeholder={t('admin.settings.branding.themeModePlaceholder')} />
</SelectTrigger>
<SelectContent>
{THEME_PRESET_IDS.map((preset) => (
@@ -460,10 +466,11 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
<SelectItem value="custom">{t('admin.settings.branding.themeModeCustom')}</SelectItem>
</SelectContent>
</Select>
</div>
</AdminFormField>
<ThemeColorInput
id="theme-primary"
label={t('admin.settings.branding.themePrimary')}
placeholder={t('admin.settings.branding.colorPlaceholder')}
value={previewTheme.primary_color}
disabled={disabled || themeMode !== 'custom'}
onChange={(primary_color) => setCustomTheme({ ...customTheme, primary_color })}
@@ -471,6 +478,7 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
<ThemeColorInput
id="theme-primary-foreground"
label={t('admin.settings.branding.themePrimaryForeground')}
placeholder={t('admin.settings.branding.colorPlaceholder')}
value={previewTheme.primary_foreground}
disabled={disabled || themeMode !== 'custom'}
onChange={(primary_foreground) => setCustomTheme({ ...customTheme, primary_foreground })}
@@ -478,6 +486,7 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
<ThemeColorInput
id="theme-canvas"
label={t('admin.settings.branding.themeCanvas')}
placeholder={t('admin.settings.branding.colorPlaceholder')}
value={previewTheme.canvas_color}
disabled={disabled || themeMode !== 'custom'}
onChange={(canvas_color) => setCustomTheme({ ...customTheme, canvas_color })}
@@ -485,6 +494,7 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
<ThemeColorInput
id="theme-sidebar-accent"
label={t('admin.settings.branding.themeSidebarAccent')}
placeholder={t('admin.settings.branding.colorPlaceholder')}
value={previewTheme.sidebar_accent_color}
disabled={disabled || themeMode !== 'custom'}
onChange={(sidebar_accent_color) => setCustomTheme({ ...customTheme, sidebar_accent_color })}
@@ -492,6 +502,7 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
<ThemeColorInput
id="theme-ring"
label={t('admin.settings.branding.themeRing')}
placeholder={t('admin.settings.branding.colorPlaceholder')}
value={previewTheme.ring_color}
disabled={disabled || themeMode !== 'custom'}
onChange={(ring_color) => setCustomTheme({ ...customTheme, ring_color })}
@@ -1,6 +1,6 @@
import type { BrandingThemeValues } from '@shared/types'
import { AdminFormField } from '@/components/admin/admin-form-drawer'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
const HEX6_RE = /^#[0-9a-fA-F]{6}$/
@@ -67,19 +67,20 @@ export function ThemePreview({ values, logoUrl }: { values: BrandingThemeValues;
export function ThemeColorInput({
id,
label,
placeholder,
value,
disabled,
onChange,
}: {
id: string
label: string
placeholder: string
value: string
disabled: boolean
onChange: (value: string) => void
}) {
return (
<div className={cn('flex flex-col gap-2', disabled && 'opacity-60')}>
<Label htmlFor={id}>{label}</Label>
<AdminFormField id={id} label={label} className={cn(disabled && 'opacity-60')}>
<div className="flex gap-2">
<Input
id={id}
@@ -89,8 +90,13 @@ export function ThemeColorInput({
onChange={(event) => onChange(event.target.value)}
className="w-12 shrink-0 px-1 py-1"
/>
<Input value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} />
<Input
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
</div>
</div>
</AdminFormField>
)
}
@@ -15,7 +15,7 @@ import { deleteStorage } from '@/lib/api'
interface DeleteStorageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
storage: { id: string; title: string } | null
storage: { id: string; bucket: string } | null
}
export function DeleteStorageDialog({ open, onOpenChange, storage }: DeleteStorageDialogProps) {
@@ -45,7 +45,7 @@ export function DeleteStorageDialog({ open, onOpenChange, storage }: DeleteStora
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.storages.deleteTitle')}</DialogTitle>
<DialogDescription>{t('admin.storages.deleteConfirm', { title: storage.title })}</DialogDescription>
<DialogDescription>{t('admin.storages.deleteConfirm', { bucket: storage.bucket })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
+94 -46
View File
@@ -1,9 +1,12 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Mail } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminFormLabel } from '@/components/admin/admin-form-drawer'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -165,32 +168,49 @@ export function EmailConfigSection() {
setForm(savedForm)
}
if (isLoading) return <p className="text-sm text-muted-foreground">{t('common.loading')}</p>
if (isLoading) {
return (
<Card data-settings-row className="rounded-lg border-border/70 py-0 shadow-xs">
<CardContent className="p-4 text-muted-foreground text-sm">{t('common.loading')}</CardContent>
</Card>
)
}
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.emailSection')}</h3>
<p className="text-sm">
{savedForm.enabled ? t('admin.auth.emailEnabled') : t('common.disabled')} ·{' '}
{savedForm.provider === 'cloudflare'
? t('admin.auth.emailCloudflare')
: savedForm.provider === 'smtp'
? t('admin.auth.emailSmtp')
: t('admin.auth.emailHttp')}
</p>
<p className="text-xs text-muted-foreground">{savedForm.from || t('admin.auth.emailNotConfigured')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setTestDialogOpen(true)} disabled={!savedForm.enabled}>
{t('admin.auth.testEmail')}
</Button>
<Button size="sm" onClick={() => setConfigOpen(true)}>
{t('common.edit')}
</Button>
</div>
</div>
<>
<Card data-settings-row className="rounded-lg border-border/70 py-0 shadow-xs">
<CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted text-muted-foreground">
<Mail className="size-4" />
</div>
<div className="min-w-0 space-y-1">
<CardTitle className="text-sm leading-5">{t('admin.auth.emailSection')}</CardTitle>
<CardDescription className="max-w-2xl leading-5">{t('admin.auth.emailEnabledHint')}</CardDescription>
<p className="text-muted-foreground text-sm">
{savedForm.provider === 'cloudflare'
? t('admin.auth.emailCloudflare')
: savedForm.provider === 'smtp'
? t('admin.auth.emailSmtp')
: t('admin.auth.emailHttp')}
{' · '}
{savedForm.from || t('admin.auth.emailNotConfigured')}
</p>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
<Badge variant={savedForm.enabled ? 'default' : 'secondary'}>
{savedForm.enabled ? t('admin.auth.enabled') : t('common.disabled')}
</Badge>
<Button variant="outline" size="sm" onClick={() => setTestDialogOpen(true)} disabled={!savedForm.enabled}>
{t('admin.auth.testEmail')}
</Button>
<Button size="sm" variant="outline" onClick={() => setConfigOpen(true)}>
{t('common.edit')}
</Button>
</div>
</CardContent>
</Card>
<AdminFormDrawer
open={configOpen}
@@ -200,7 +220,7 @@ export function EmailConfigSection() {
}}
title={t('admin.auth.emailSection')}
description={t('admin.auth.emailEnabledHint')}
bodyClassName="grid gap-4"
bodyClassName="grid auto-rows-min content-start gap-4"
formProps={{
onSubmit: (event) => {
event.preventDefault()
@@ -218,18 +238,17 @@ export function EmailConfigSection() {
</>
}
>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label htmlFor="emailEnabled">{t('admin.auth.emailEnabled')}</Label>
<p className="text-sm text-muted-foreground">{t('admin.auth.emailEnabledHint')}</p>
</div>
<div className="flex items-center justify-between gap-4">
<AdminFormLabel htmlFor="emailEnabled" help={t('admin.auth.emailEnabledHint')}>
{t('admin.auth.emailEnabled')}
</AdminFormLabel>
<Switch id="emailEnabled" checked={form.enabled} onCheckedChange={(v) => update({ enabled: !!v })} />
</div>
<AdminFormField id="email-provider" label={t('admin.auth.emailProvider')}>
<AdminFormField id="email-provider" label={t('admin.auth.emailProvider')} required>
<Select value={form.provider} onValueChange={(v) => update({ provider: v as ProviderType })}>
<SelectTrigger id="email-provider">
<SelectValue />
<SelectValue placeholder={t('admin.auth.emailProviderPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="cloudflare">{t('admin.auth.emailCloudflare')}</SelectItem>
@@ -239,29 +258,48 @@ export function EmailConfigSection() {
</Select>
</AdminFormField>
<AdminFormField id="email-from" label={t('admin.auth.emailFrom')}>
<Input type="email" value={form.from} onChange={(e) => update({ from: e.target.value })} />
<AdminFormField id="email-from" label={t('admin.auth.emailFrom')} required>
<Input
type="email"
value={form.from}
placeholder={t('admin.auth.emailFromPlaceholder')}
onChange={(e) => update({ from: e.target.value })}
/>
</AdminFormField>
{form.provider === 'smtp' ? (
<>
<div className="grid grid-cols-2 gap-4">
<AdminFormField id="smtp-host" label={t('admin.auth.smtpHost')}>
<Input value={form.smtpHost} onChange={(e) => update({ smtpHost: e.target.value })} />
<AdminFormField id="smtp-host" label={t('admin.auth.smtpHost')} required>
<Input
value={form.smtpHost}
placeholder={t('admin.auth.smtpHostPlaceholder')}
onChange={(e) => update({ smtpHost: e.target.value })}
/>
</AdminFormField>
<AdminFormField id="smtp-port" label={t('admin.auth.smtpPort')}>
<AdminFormField id="smtp-port" label={t('admin.auth.smtpPort')} required>
<Input
type="number"
value={form.smtpPort}
placeholder={t('admin.auth.smtpPortPlaceholder')}
onChange={(e) => update({ smtpPort: Number(e.target.value) })}
/>
</AdminFormField>
</div>
<AdminFormField id="smtp-user" label={t('admin.auth.smtpUser')}>
<Input value={form.smtpUser} onChange={(e) => update({ smtpUser: e.target.value })} />
<Input
value={form.smtpUser}
placeholder={t('admin.auth.smtpUserPlaceholder')}
onChange={(e) => update({ smtpUser: e.target.value })}
/>
</AdminFormField>
<AdminFormField id="smtp-pass" label={t('admin.auth.smtpPass')}>
<Input type="password" value={form.smtpPass} onChange={(e) => update({ smtpPass: e.target.value })} />
<Input
type="password"
value={form.smtpPass}
placeholder={t('admin.auth.smtpPassPlaceholder')}
onChange={(e) => update({ smtpPass: e.target.value })}
/>
</AdminFormField>
<div className="flex items-center gap-2">
<Checkbox
@@ -274,11 +312,20 @@ export function EmailConfigSection() {
</>
) : form.provider === 'http' ? (
<>
<AdminFormField id="email-http-url" label={t('admin.auth.httpUrl')}>
<Input value={form.httpUrl} onChange={(e) => update({ httpUrl: e.target.value })} />
<AdminFormField id="email-http-url" label={t('admin.auth.httpUrl')} required>
<Input
value={form.httpUrl}
placeholder={t('admin.auth.httpUrlPlaceholder')}
onChange={(e) => update({ httpUrl: e.target.value })}
/>
</AdminFormField>
<AdminFormField id="email-http-api-key" label={t('admin.auth.httpApiKey')}>
<Input type="password" value={form.httpApiKey} onChange={(e) => update({ httpApiKey: e.target.value })} />
<AdminFormField id="email-http-api-key" label={t('admin.auth.httpApiKey')} required>
<Input
type="password"
value={form.httpApiKey}
placeholder={t('admin.auth.httpApiKeyPlaceholder')}
onChange={(e) => update({ httpApiKey: e.target.value })}
/>
</AdminFormField>
</>
) : null}
@@ -289,6 +336,7 @@ export function EmailConfigSection() {
onOpenChange={setTestDialogOpen}
title={t('admin.auth.testEmail')}
description={t('admin.auth.testEmailTo')}
bodyClassName="grid auto-rows-min content-start gap-4"
formProps={{
onSubmit: (event) => {
event.preventDefault()
@@ -306,7 +354,7 @@ export function EmailConfigSection() {
</>
}
>
<AdminFormField id="test-email-address" label={t('admin.auth.testEmailTo')}>
<AdminFormField id="test-email-address" label={t('admin.auth.testEmailTo')} required>
<Input
type="email"
value={testEmailAddr}
@@ -315,6 +363,6 @@ export function EmailConfigSection() {
/>
</AdminFormField>
</AdminFormDrawer>
</div>
</>
)
}
@@ -135,8 +135,7 @@ describe('OAuthProvidersSection', () => {
const addButton = await screen.findByRole('button', { name: 'admin.auth.addProvider' })
await waitFor(() => expect((addButton as HTMLButtonElement).disabled).toBe(false))
fireEvent.click(addButton)
fireEvent.click(screen.getByRole('combobox', { name: 'admin.auth.providerType' }))
fireEvent.click(screen.getByRole('option', { name: 'admin.auth.providerOidc' }))
fireEvent.click(screen.getByRole('radio', { name: 'admin.auth.providerOidc' }))
fireEvent.change(screen.getByLabelText('admin.auth.providerId'), { target: { value: 'new-sso' } })
await waitFor(() => {
@@ -5,7 +5,7 @@ import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminFormLabel } from '@/components/admin/admin-form-drawer'
import { AdminPageHeader } from '@/components/admin/admin-page-header'
import { OAuthProviderIcon } from '@/components/oauth-provider-icon'
import { Button } from '@/components/ui/button'
@@ -18,10 +18,10 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { useClipboard } from '@/hooks/use-clipboard'
import { deleteAuthProvider, listAuthProviders, upsertAuthProvider } from '@/lib/api'
@@ -231,7 +231,7 @@ export function OAuthProvidersSection() {
title={editingId ? t('admin.auth.editProviderTitle') : t('admin.auth.addProviderTitle')}
description={t('admin.auth.providerDrawerDescription')}
width="wide"
bodyClassName="grid gap-4"
bodyClassName="grid auto-rows-min content-start gap-4"
formProps={{
onSubmit: (event) => {
event.preventDefault()
@@ -252,30 +252,34 @@ export function OAuthProvidersSection() {
</>
}
>
<AdminFormField id="oauth-provider-type" label={t('admin.auth.providerType')}>
{(controlProps) => (
<Select
value={form.type}
onValueChange={(v) => update({ type: v as ProviderType, providerId: '' })}
disabled={!!editingId}
>
<SelectTrigger {...controlProps}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="builtin">{t('admin.auth.providerBuiltin')}</SelectItem>
<SelectItem value="oidc">{t('admin.auth.providerOidc')}</SelectItem>
</SelectContent>
</Select>
)}
</AdminFormField>
<div className="space-y-1">
<AdminFormLabel htmlFor="oauth-provider-type" required>
{t('admin.auth.providerType')}
</AdminFormLabel>
<ToggleGroup
id="oauth-provider-type"
type="single"
value={form.type}
variant="outline"
disabled={!!editingId}
onValueChange={(value) => value && update({ type: value as ProviderType, providerId: '' })}
className="w-full"
>
<ToggleGroupItem value="builtin" className="flex-1">
{t('admin.auth.providerBuiltin')}
</ToggleGroupItem>
<ToggleGroupItem value="oidc" className="flex-1">
{t('admin.auth.providerOidc')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{form.type === 'builtin' ? (
<AdminFormField id="oauth-provider-id" label={t('admin.auth.provider')}>
<AdminFormField id="oauth-provider-id" label={t('admin.auth.provider')} required>
{(controlProps) => (
<Select value={form.providerId} onValueChange={(v) => update({ providerId: v })} disabled={!!editingId}>
<SelectTrigger {...controlProps}>
<SelectValue />
<SelectValue placeholder={t('admin.auth.providerPlaceholder')} />
</SelectTrigger>
<SelectContent>
{BUILTIN_PROVIDER_IDS.map((id) => (
@@ -288,21 +292,43 @@ export function OAuthProvidersSection() {
)}
</AdminFormField>
) : (
<AdminFormField id="oauth-provider-id" label={t('admin.auth.providerId')}>
<AdminFormField
id="oauth-provider-id"
label={t('admin.auth.providerId')}
help={t('admin.auth.providerIdHint')}
required
>
<Input
value={form.providerId}
onChange={(e) => update({ providerId: e.target.value })}
placeholder={t('admin.auth.providerIdHint')}
placeholder={t('admin.auth.providerIdPlaceholder')}
disabled={!!editingId}
/>
</AdminFormField>
)}
<AdminFormField id="oauth-client-id" label={t('admin.auth.clientId')} required>
<Input
value={form.clientId}
onChange={(e) => update({ clientId: e.target.value })}
placeholder={t('admin.auth.clientIdPlaceholder')}
/>
</AdminFormField>
<AdminFormField id="oauth-client-secret" label={t('admin.auth.clientSecret')} required>
<Input
type="password"
value={form.clientSecret}
onChange={(e) => update({ clientSecret: e.target.value })}
placeholder={t('admin.auth.clientSecretPlaceholder')}
/>
</AdminFormField>
{callbackUri && (
<AdminFormField
id="oauth-callback-uri"
label={t('admin.auth.callbackUri')}
description={t('admin.auth.callbackUriHint')}
help={t('admin.auth.callbackUriHint')}
>
{(controlProps) => (
<div className="flex items-center gap-2">
@@ -321,38 +347,32 @@ export function OAuthProvidersSection() {
</AdminFormField>
)}
<AdminFormField id="oauth-client-id" label={t('admin.auth.clientId')}>
<Input value={form.clientId} onChange={(e) => update({ clientId: e.target.value })} />
</AdminFormField>
<AdminFormField id="oauth-client-secret" label={t('admin.auth.clientSecret')}>
<Input type="password" value={form.clientSecret} onChange={(e) => update({ clientSecret: e.target.value })} />
</AdminFormField>
{form.type === 'oidc' && (
<>
<AdminFormField id="oauth-discovery-url" label={t('admin.auth.discoveryUrl')}>
<Input value={form.discoveryUrl} onChange={(e) => update({ discoveryUrl: e.target.value })} />
<Input
value={form.discoveryUrl}
onChange={(e) => update({ discoveryUrl: e.target.value })}
placeholder={t('admin.auth.discoveryUrlPlaceholder')}
/>
</AdminFormField>
<AdminFormField id="oauth-scopes" label={t('admin.auth.scopes')}>
<AdminFormField id="oauth-scopes" label={t('admin.auth.scopes')} help={t('admin.auth.scopesHint')}>
<Input
value={form.scopes}
onChange={(e) => update({ scopes: e.target.value })}
placeholder={t('admin.auth.scopesHint')}
placeholder={t('admin.auth.scopesPlaceholder')}
/>
</AdminFormField>
</>
)}
<div className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="oauth-provider-enabled">{t('admin.auth.enabled')}</Label>
<Switch
id="oauth-provider-enabled"
checked={form.enabled}
onCheckedChange={(checked) => update({ enabled: checked })}
/>
</div>
<div className="flex items-center justify-between gap-4">
<AdminFormLabel htmlFor="oauth-provider-enabled">{t('admin.auth.enabled')}</AdminFormLabel>
<Switch
id="oauth-provider-enabled"
checked={form.enabled}
onCheckedChange={(checked) => update({ enabled: checked })}
/>
</div>
<p className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
@@ -32,7 +32,6 @@ vi.mock('@/lib/api', () => ({
const storage: Storage = {
id: 'storage-1',
title: 'Primary storage',
bucket: 'bucket',
endpoint: 'https://s3.example.com',
region: 'auto',
@@ -79,7 +78,6 @@ describe('StorageFormDrawer', () => {
const onOpenChange = vi.fn()
renderStorageFormDrawer({ onOpenChange })
fireEvent.change(screen.getByLabelText('admin.storages.fieldTitle'), { target: { value: 'New storage' } })
fireEvent.change(screen.getByLabelText('admin.storages.fieldBucket'), { target: { value: 'new-bucket' } })
fireEvent.change(screen.getByLabelText('admin.storages.fieldEndpoint'), {
target: { value: 'https://storage.example.com' },
@@ -87,22 +85,20 @@ describe('StorageFormDrawer', () => {
fireEvent.change(screen.getByLabelText('admin.storages.fieldRegion'), { target: { value: 'auto' } })
fireEvent.change(screen.getByLabelText('admin.storages.fieldAccessKey'), { target: { value: 'new-access' } })
fireEvent.change(screen.getByLabelText('admin.storages.fieldSecretKey'), { target: { value: 'new-secret' } })
fireEvent.change(screen.getByLabelText('admin.storages.fieldCapacity'), { target: { value: '2' } })
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() =>
expect(createStorage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'New storage',
bucket: 'new-bucket',
endpoint: 'https://storage.example.com',
region: 'auto',
accessKey: 'new-access',
secretKey: 'new-secret',
capacity: 2 * 1024 * 1024 * 1024,
}),
),
)
expect(createStorage).not.toHaveBeenCalledWith(expect.objectContaining({ capacity: expect.any(Number) }))
expect(createStorage).not.toHaveBeenCalledWith(expect.objectContaining({ egressCreditBillingEnabled: false }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
@@ -117,17 +113,14 @@ describe('StorageFormDrawer', () => {
fireEvent.click(screen.getByRole('button', { name: 'admin.storages.showSecretKey' }))
expect(secretInput.getAttribute('type')).toBe('text')
expect(screen.getByRole('button', { name: 'admin.storages.hideSecretKey' })).toBeTruthy()
expect((screen.getByLabelText('admin.storages.fieldCapacity') as HTMLInputElement).value).toBe('2')
expect(screen.queryByLabelText('admin.storages.fieldCapacity')).toBeNull()
expect(screen.queryByLabelText('admin.storages.egressBillingUnit')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() =>
expect(updateStorage).toHaveBeenCalledWith(
'storage-1',
expect.objectContaining({
title: 'Primary storage',
capacity: 2 * 1024 * 1024 * 1024,
}),
expect.not.objectContaining({ capacity: expect.any(Number) }),
),
)
expect(updateStorage).not.toHaveBeenCalledWith('storage-1', expect.objectContaining({ egressCreditPerUnit: 3 }))
+25 -91
View File
@@ -7,50 +7,31 @@ import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminFormLabel } from '@/components/admin/admin-form-drawer'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { createStorage, updateStorage } from '@/lib/api'
import { formatSize } from '@/lib/format'
const UNITS = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const
type Unit = keyof typeof UNITS
function bytesToDisplay(bytes: number): { value: number; unit: Unit } {
if (bytes === 0) return { value: 0, unit: 'GB' }
if (bytes >= UNITS.TB && bytes % UNITS.TB === 0) return { value: bytes / UNITS.TB, unit: 'TB' }
if (bytes >= UNITS.GB && bytes % UNITS.GB === 0) return { value: bytes / UNITS.GB, unit: 'GB' }
return { value: bytes / UNITS.MB, unit: 'MB' }
}
const storageFormSchema = z.object({
title: z.string().min(1),
bucket: z.string().min(1),
endpoint: z.string().url(),
region: z.string().min(1),
accessKey: z.string().min(1),
secretKey: z.string().min(1),
customHost: z.string().optional(),
capacityValue: z.coerce.number<number>().min(0),
capacityUnit: z.enum(['MB', 'GB', 'TB']),
forcePathStyle: z.boolean(),
})
type StorageFormValues = z.infer<typeof storageFormSchema>
const DEFAULT_VALUES: StorageFormValues = {
title: '',
bucket: '',
endpoint: '',
region: 'auto',
accessKey: '',
secretKey: '',
customHost: '',
capacityValue: 0,
capacityUnit: 'GB',
forcePathStyle: true,
}
@@ -74,17 +55,13 @@ export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDr
useEffect(() => {
if (!open) return
if (storage) {
const { value, unit } = bytesToDisplay(storage.capacity ?? 0)
form.reset({
title: storage.title,
bucket: storage.bucket,
endpoint: storage.endpoint,
region: storage.region,
accessKey: storage.accessKey,
secretKey: storage.secretKey,
customHost: storage.customHost || '',
capacityValue: value,
capacityUnit: unit,
forcePathStyle: storage.forcePathStyle ?? true,
})
} else {
@@ -94,14 +71,7 @@ export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDr
}, [open, storage, form])
const mutation = useMutation({
mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => {
const capacity = capacityValue * UNITS[capacityUnit]
const payload = {
...rest,
capacity,
}
return isEditing ? updateStorage(storage.id, payload) : createStorage(payload)
},
mutationFn: (values: StorageFormValues) => (isEditing ? updateStorage(storage.id, values) : createStorage(values)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
onOpenChange(false)
@@ -121,7 +91,7 @@ export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDr
open={open}
onOpenChange={onOpenChange}
title={isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')}
bodyClassName="grid gap-4"
bodyClassName="grid auto-rows-min content-start gap-4"
formProps={{ onSubmit: form.handleSubmit(onSubmit) }}
footer={
<>
@@ -134,49 +104,46 @@ export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDr
</>
}
>
<AdminFormField
id="storage-title"
label={t('admin.storages.fieldTitle')}
error={form.formState.errors.title?.message}
>
<Input {...form.register('title')} />
</AdminFormField>
<AdminFormField
id="storage-bucket"
label={t('admin.storages.fieldBucket')}
required
error={form.formState.errors.bucket?.message}
>
<Input {...form.register('bucket')} />
<Input {...form.register('bucket')} placeholder={t('admin.storages.bucketPlaceholder')} />
</AdminFormField>
<AdminFormField
id="storage-endpoint"
label={t('admin.storages.fieldEndpoint')}
required
error={form.formState.errors.endpoint?.message}
>
<Input {...form.register('endpoint')} placeholder="https://s3.amazonaws.com" />
<Input {...form.register('endpoint')} placeholder={t('admin.storages.endpointPlaceholder')} />
</AdminFormField>
<AdminFormField
id="storage-region"
label={t('admin.storages.fieldRegion')}
required
error={form.formState.errors.region?.message}
>
<Input {...form.register('region')} placeholder="auto" />
<Input {...form.register('region')} placeholder={t('admin.storages.regionPlaceholder')} />
</AdminFormField>
<AdminFormField
id="storage-access-key"
label={t('admin.storages.fieldAccessKey')}
required
error={form.formState.errors.accessKey?.message}
>
<Input {...form.register('accessKey')} />
<Input {...form.register('accessKey')} placeholder={t('admin.storages.accessKeyPlaceholder')} />
</AdminFormField>
<AdminFormField
id="storage-secret-key"
label={t('admin.storages.fieldSecretKey')}
required
error={form.formState.errors.secretKey?.message}
>
{(controlProps) => (
@@ -185,6 +152,7 @@ export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDr
{...form.register('secretKey')}
{...controlProps}
type={showSecret ? 'text' : 'password'}
placeholder={t('admin.storages.secretKeyPlaceholder')}
className="pr-10"
/>
<Button
@@ -204,59 +172,25 @@ export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDr
<AdminFormField
id="storage-custom-host"
label={t('admin.storages.fieldCustomHost')}
help={t('admin.storages.customHostHint')}
error={form.formState.errors.customHost?.message}
>
<Input {...form.register('customHost')} placeholder={t('admin.storages.customHostPlaceholder')} />
</AdminFormField>
<div className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<Label htmlFor="forcePathStyle">{t('admin.storages.fieldForcePathStyle')}</Label>
<p className="text-xs text-muted-foreground">{t('admin.storages.forcePathStyleHint')}</p>
</div>
<Switch
id="forcePathStyle"
checked={form.watch('forcePathStyle')}
onCheckedChange={(checked) => form.setValue('forcePathStyle', checked)}
/>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<AdminFormLabel htmlFor="forcePathStyle" help={t('admin.storages.forcePathStyleHint')}>
{t('admin.storages.fieldForcePathStyle')}
</AdminFormLabel>
</div>
<Switch
id="forcePathStyle"
className="mt-0.5"
checked={form.watch('forcePathStyle')}
onCheckedChange={(checked) => form.setValue('forcePathStyle', checked)}
/>
</div>
<AdminFormField
id="storage-capacity-value"
label={t('admin.storages.fieldCapacity')}
description={t('admin.storages.capacityHint')}
error={form.formState.errors.capacityValue?.message}
>
{(controlProps) => (
<div className="flex items-center gap-2">
<Input
{...form.register('capacityValue')}
{...controlProps}
type="number"
min={0}
step={1}
className="w-32"
/>
<Select value={form.watch('capacityUnit')} onValueChange={(v) => form.setValue('capacityUnit', v as Unit)}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
<SelectItem value="TB">TB</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{form.watch('capacityValue') > 0
? `= ${formatSize(form.watch('capacityValue') * UNITS[form.watch('capacityUnit')])}`
: t('admin.storages.capacityUnlimited')}
</span>
</div>
)}
</AdminFormField>
</AdminFormDrawer>
)
}
@@ -367,15 +367,15 @@ function RecipientsField({
function PasswordField({ enabled, onToggle }: { enabled: boolean; onToggle: (v: boolean) => void }) {
const { t } = useTranslation()
return (
<div className="space-y-1.5 rounded-md border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div className="flex min-h-11 items-start justify-between gap-4 rounded-md border bg-background p-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="share-pwd">{t('share.password')}</Label>
</div>
<Switch id="share-pwd" checked={enabled} onCheckedChange={onToggle} />
<p className="text-xs leading-5 text-muted-foreground">{t('share.passwordHint')}</p>
</div>
<p className="text-xs text-muted-foreground">{t('share.passwordHint')}</p>
<Switch id="share-pwd" className="mt-0.5" checked={enabled} onCheckedChange={onToggle} />
</div>
)
}
+14
View File
@@ -48,17 +48,23 @@ const ADMIN_AUTH_KEYS = [
'admin.auth.providerBuiltin',
'admin.auth.providerOidc',
'admin.auth.provider',
'admin.auth.providerPlaceholder',
'admin.auth.callbackUri',
'admin.auth.callbackUriHint',
'admin.auth.copyCallbackUri',
'admin.auth.callbackUriCopied',
'admin.auth.clientId',
'admin.auth.clientIdPlaceholder',
'admin.auth.clientSecret',
'admin.auth.clientSecretPlaceholder',
'admin.auth.enabled',
'admin.auth.discoveryUrl',
'admin.auth.discoveryUrlPlaceholder',
'admin.auth.scopes',
'admin.auth.scopesPlaceholder',
'admin.auth.scopesHint',
'admin.auth.providerId',
'admin.auth.providerIdPlaceholder',
'admin.auth.providerIdHint',
'admin.auth.providerSaved',
'admin.auth.providerDeleted',
@@ -70,17 +76,25 @@ const ADMIN_AUTH_KEYS = [
'admin.auth.emailEnabled',
'admin.auth.emailEnabledHint',
'admin.auth.emailProvider',
'admin.auth.emailProviderPlaceholder',
'admin.auth.emailCloudflare',
'admin.auth.emailSmtp',
'admin.auth.emailHttp',
'admin.auth.emailFrom',
'admin.auth.emailFromPlaceholder',
'admin.auth.smtpHost',
'admin.auth.smtpHostPlaceholder',
'admin.auth.smtpPort',
'admin.auth.smtpPortPlaceholder',
'admin.auth.smtpUser',
'admin.auth.smtpUserPlaceholder',
'admin.auth.smtpPass',
'admin.auth.smtpPassPlaceholder',
'admin.auth.smtpSecure',
'admin.auth.httpUrl',
'admin.auth.httpUrlPlaceholder',
'admin.auth.httpApiKey',
'admin.auth.httpApiKeyPlaceholder',
'admin.auth.testEmail',
'admin.auth.testEmailTo',
'admin.auth.testEmailSent',
+4
View File
@@ -12,6 +12,10 @@ const ADMIN_SETTINGS_KEYS = [
'admin.settings.siteDescription',
'admin.settings.registrationTitle',
'admin.settings.registrationLabel',
'admin.settings.branding.themeModePlaceholder',
'admin.settings.branding.colorPlaceholder',
'admin.settings.captchaProviderPlaceholder',
'admin.settings.quotaValuePlaceholder',
'admin.settings.saved',
'admin.settings.identityInvalid',
'admin.settings.positiveQuotaRequired',
+28 -11
View File
@@ -18,8 +18,8 @@ const ADMIN_STORAGES_KEYS = [
'admin.storages.updated',
'admin.storages.deleted',
'admin.storages.noStorages',
'admin.storages.colTitle',
'admin.storages.colBucket',
'admin.storages.colAccessKey',
'admin.storages.colEndpoint',
'admin.storages.colEgressBilling',
'admin.storages.colStatus',
@@ -30,38 +30,55 @@ const ADMIN_STORAGES_KEYS = [
'admin.storages.healthUntested',
'admin.storages.healthTesting',
'admin.storages.testAction',
'admin.storages.testDialogTitle',
'admin.storages.testStepCreate',
'admin.storages.testStepUpload',
'admin.storages.testStepCleanup',
'admin.storages.testStepDone',
'admin.storages.testStepFailed',
'admin.storages.testStepRunning',
'admin.storages.testStepPending',
'admin.storages.testSuccess',
'admin.storages.testNoUploadUrl',
'admin.storages.testUploadFailed',
'admin.storages.testCleanupFailed',
'admin.storages.testCorsFailure',
'admin.storages.testCorsConfig',
'admin.storages.testCorsCaveat',
'admin.storages.fieldTitle',
'admin.storages.fieldBucket',
'admin.storages.bucketPlaceholder',
'admin.storages.fieldEndpoint',
'admin.storages.endpointPlaceholder',
'admin.storages.fieldRegion',
'admin.storages.regionPlaceholder',
'admin.storages.fieldAccessKey',
'admin.storages.accessKeyPlaceholder',
'admin.storages.fieldSecretKey',
'admin.storages.secretKeyPlaceholder',
'admin.storages.showSecretKey',
'admin.storages.hideSecretKey',
'admin.storages.fieldCustomHost',
'admin.storages.customHostHint',
'admin.storages.fieldForcePathStyle',
'admin.storages.forcePathStyleHint',
'admin.storages.customHostPlaceholder',
'admin.storages.fieldCapacity',
'admin.storages.capacityPlaceholder',
'admin.storages.capacityUnlimited',
'admin.storages.capacityHint',
'admin.storages.egressBilling',
'admin.storages.configureEgressBilling',
'admin.storages.egressBillingTitle',
'admin.storages.egressBillingDescription',
'admin.storages.billingTitle',
'admin.storages.billingDescription',
'admin.storages.egressBillingHint',
'admin.storages.egressBillingBusinessOnly',
'admin.storages.egressBillingUnit',
'admin.storages.egressBillingUnitPlaceholder',
'admin.storages.egressBillingCredits',
'admin.storages.egressBillingCreditsPlaceholder',
'admin.storages.egressBillingRate',
'admin.storages.egressBillingOff',
'admin.storages.egressBillingSaveSuccess',
'admin.storages.billingSaveSuccess',
]
const ADMIN_NAV_KEYS = ['admin.nav.management', 'admin.nav.storages', 'admin.nav.users']
@@ -72,10 +89,10 @@ const ALL_KEYS = [...ADMIN_STORAGES_KEYS, ...ADMIN_NAV_KEYS, ...SHARED_KEYS]
// Keys that contain interpolation placeholders and the expected placeholder tokens
const INTERPOLATED_KEYS: Record<string, string[]> = {
'admin.storages.deleteConfirm': ['{{title}}'],
'admin.storages.deleteConfirm': ['{{bucket}}'],
'admin.storages.testUploadFailed': ['{{detail}}'],
'admin.storages.testCleanupFailed': ['{{detail}}'],
'admin.storages.egressBillingDescription': ['{{title}}'],
'admin.storages.billingDescription': ['{{bucket}}'],
'admin.storages.egressBillingRate': ['{{credits}}', '{{unit}}'],
}
@@ -276,18 +293,18 @@ describe('admin.storages locale keys — i18n runtime translation', () => {
expect(i18n.t('admin.storages.deleteTitle')).toBe('删除存储')
})
it('interpolates admin.storages.deleteConfirm with title in English', async () => {
it('interpolates admin.storages.deleteConfirm with bucket in English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
const result = i18n.t('admin.storages.deleteConfirm', { title: 'my-bucket' })
const result = i18n.t('admin.storages.deleteConfirm', { bucket: 'my-bucket' })
expect(result).toContain('my-bucket')
expect(result).toContain('cannot be undone')
})
it('interpolates admin.storages.deleteConfirm with title in Chinese', async () => {
it('interpolates admin.storages.deleteConfirm with bucket in Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
const result = i18n.t('admin.storages.deleteConfirm', { title: 'my-bucket' })
const result = i18n.t('admin.storages.deleteConfirm', { bucket: 'my-bucket' })
expect(result).toContain('my-bucket')
})
+43 -9
View File
@@ -331,7 +331,6 @@
"admin.nav.users": "Users",
"admin.nav.settings": "Settings",
"admin.nav.cloudStore": "Storage Plans",
"admin.nav.email": "Email",
"admin.nav.about": "About",
"admin.overview.title": "Admin overview",
"admin.overview.subtitle": "Key operational signals for users, storage, quota, and invitations.",
@@ -546,6 +545,7 @@
"admin.settings.captchaEnabled": "Require captcha",
"admin.settings.captchaEnabledHint": "When enabled, users must complete the selected captcha before password authentication.",
"admin.settings.captchaProvider": "Captcha provider",
"admin.settings.captchaProviderPlaceholder": "Select captcha provider",
"admin.settings.captchaProviderTurnstile": "Cloudflare Turnstile",
"admin.settings.captchaProviderRecaptcha": "Google reCAPTCHA",
"admin.settings.captchaProviderHcaptcha": "hCaptcha",
@@ -561,6 +561,7 @@
"admin.settings.captchaMinScorePlaceholder": "0.5",
"admin.settings.captchaMinScoreHint": "Optional Google reCAPTCHA v3 score threshold from 0 to 1.",
"admin.settings.captchaInvalid": "Captcha settings are invalid",
"admin.settings.quotaValuePlaceholder": "1",
"admin.settings.siteNamePlaceholder": "ZPan Workspace",
"admin.settings.siteNameHint": "Keep it short and recognizable. It will be reused in navigation and browser chrome.",
"admin.settings.siteDescriptionPlaceholder": "Private file hosting for your team, customers, or community.",
@@ -607,9 +608,11 @@
"admin.settings.branding.themeTitle": "Theme",
"admin.settings.branding.themeDescription": "Choose a built-in ZPan theme or set custom colors for the app shell.",
"admin.settings.branding.themeMode": "Theme source",
"admin.settings.branding.themeModePlaceholder": "Select theme source",
"admin.settings.branding.themeModePreset": "Built-in theme",
"admin.settings.branding.themeModeCustom": "Custom colors",
"admin.settings.branding.themePreset": "Built-in theme",
"admin.settings.branding.colorPlaceholder": "#000000",
"admin.settings.branding.themePrimary": "Primary",
"admin.settings.branding.themePrimaryForeground": "Primary text",
"admin.settings.branding.themeCanvas": "Canvas accent",
@@ -668,22 +671,28 @@
"admin.auth.addProvider": "Add Provider",
"admin.auth.addProviderTitle": "Add OAuth Provider",
"admin.auth.editProviderTitle": "Edit OAuth Provider",
"admin.auth.providerDrawerDescription": "Provider settings are saved immediately for admin display. The auth runtime may need a restart before using changes.",
"admin.auth.providerDrawerDescription": "OAuth sign-in provider.",
"admin.auth.providerType": "Provider Type",
"admin.auth.providerBuiltin": "Built-in Provider",
"admin.auth.providerOidc": "Custom OIDC",
"admin.auth.provider": "Provider",
"admin.auth.providerPlaceholder": "Select provider",
"admin.auth.callbackUri": "Callback URI",
"admin.auth.callbackUriHint": "Use this redirect URI in the OAuth provider console. If BETTER_AUTH_URL uses a different origin, keep the same path on that auth origin.",
"admin.auth.copyCallbackUri": "Copy callback URI",
"admin.auth.callbackUriCopied": "Callback URI copied",
"admin.auth.clientId": "Client ID",
"admin.auth.clientIdPlaceholder": "OAuth client ID",
"admin.auth.clientSecret": "Client Secret",
"admin.auth.clientSecretPlaceholder": "OAuth client secret",
"admin.auth.enabled": "Enabled",
"admin.auth.discoveryUrl": "Discovery URL",
"admin.auth.discoveryUrlPlaceholder": "https://accounts.example.com/.well-known/openid-configuration",
"admin.auth.scopes": "Scopes",
"admin.auth.scopesPlaceholder": "openid,email,profile",
"admin.auth.scopesHint": "Comma-separated list of scopes",
"admin.auth.providerId": "Provider ID",
"admin.auth.providerIdPlaceholder": "company-sso",
"admin.auth.providerIdHint": "Lowercase letters, numbers, and hyphens only",
"admin.auth.providerSaved": "Provider saved",
"admin.auth.providerDeleted": "Provider deleted",
@@ -697,17 +706,25 @@
"admin.auth.emailEnabled": "Enable Email",
"admin.auth.emailEnabledHint": "Controls whether any email provider is allowed to send messages.",
"admin.auth.emailProvider": "Provider Type",
"admin.auth.emailProviderPlaceholder": "Select email provider",
"admin.auth.emailCloudflare": "Cloudflare Email",
"admin.auth.emailSmtp": "SMTP",
"admin.auth.emailHttp": "HTTP API",
"admin.auth.emailFrom": "From Address",
"admin.auth.emailFromPlaceholder": "noreply@example.com",
"admin.auth.smtpHost": "Host",
"admin.auth.smtpHostPlaceholder": "smtp.example.com",
"admin.auth.smtpPort": "Port",
"admin.auth.smtpPortPlaceholder": "587",
"admin.auth.smtpUser": "Username",
"admin.auth.smtpUserPlaceholder": "smtp-user",
"admin.auth.smtpPass": "Password",
"admin.auth.smtpPassPlaceholder": "SMTP password",
"admin.auth.smtpSecure": "Secure (TLS)",
"admin.auth.httpUrl": "API URL",
"admin.auth.httpUrlPlaceholder": "https://api.example.com/send-email",
"admin.auth.httpApiKey": "API Key",
"admin.auth.httpApiKeyPlaceholder": "HTTP API key",
"admin.auth.testEmail": "Send Test Email",
"admin.auth.testEmailTo": "Recipient Email",
"admin.auth.testEmailSent": "Test email sent",
@@ -720,14 +737,14 @@
"admin.storages.addTitle": "Add Storage",
"admin.storages.editTitle": "Edit Storage",
"admin.storages.deleteTitle": "Delete Storage",
"admin.storages.deleteConfirm": "Delete storage '{{title}}'? This cannot be undone.",
"admin.storages.deleteConfirm": "Delete storage bucket '{{bucket}}'? This cannot be undone.",
"admin.storages.deleteHasFiles": "Cannot delete storage that contains files.",
"admin.storages.created": "Storage created",
"admin.storages.updated": "Storage updated",
"admin.storages.deleted": "Storage deleted",
"admin.storages.noStorages": "No storages configured",
"admin.storages.colTitle": "Title",
"admin.storages.colBucket": "Bucket",
"admin.storages.colAccessKey": "Access Key",
"admin.storages.colEndpoint": "Endpoint",
"admin.storages.colEgressBilling": "Egress billing",
"admin.storages.colStatus": "Status",
@@ -738,38 +755,55 @@
"admin.storages.healthUntested": "Not tested",
"admin.storages.healthTesting": "Testing...",
"admin.storages.testAction": "Test connection",
"admin.storages.testDialogTitle": "Storage connection test",
"admin.storages.testStepCreate": "Create temporary upload",
"admin.storages.testStepUpload": "Upload through presigned URL",
"admin.storages.testStepCleanup": "Clean up test object",
"admin.storages.testStepDone": "Done",
"admin.storages.testStepFailed": "Failed",
"admin.storages.testStepRunning": "Running",
"admin.storages.testStepPending": "Pending",
"admin.storages.testSuccess": "Upload test succeeded",
"admin.storages.testNoUploadUrl": "The server did not return an upload URL.",
"admin.storages.testUploadFailed": "Upload failed: {{detail}}",
"admin.storages.testCleanupFailed": "Cleanup failed: {{detail}}",
"admin.storages.testCorsFailure": "The browser could not reach the presigned upload URL. This is usually a bucket CORS or endpoint reachability issue.",
"admin.storages.testCorsConfig": "Bucket CORS policy",
"admin.storages.testCorsCaveat": "Apply CORS like this for the current admin origin, then also verify endpoint, region, bucket name, and credentials.",
"admin.storages.fieldTitle": "Title",
"admin.storages.fieldBucket": "Bucket",
"admin.storages.bucketPlaceholder": "my-bucket",
"admin.storages.fieldEndpoint": "Endpoint",
"admin.storages.endpointPlaceholder": "https://s3.amazonaws.com",
"admin.storages.fieldRegion": "Region",
"admin.storages.regionPlaceholder": "auto",
"admin.storages.fieldAccessKey": "Access Key",
"admin.storages.accessKeyPlaceholder": "AKIA...",
"admin.storages.fieldSecretKey": "Secret Key",
"admin.storages.secretKeyPlaceholder": "Secret access key",
"admin.storages.showSecretKey": "Show secret key",
"admin.storages.hideSecretKey": "Hide secret key",
"admin.storages.fieldCustomHost": "Custom Host",
"admin.storages.customHostHint": "Optional public domain used when generating file URLs for this storage.",
"admin.storages.fieldForcePathStyle": "Path Style",
"admin.storages.forcePathStyleHint": "Use path-style addressing (endpoint/bucket/key). Turn off for virtual-hosted-style (bucket.endpoint/key).",
"admin.storages.fieldCapacity": "Capacity",
"admin.storages.capacityPlaceholder": "0",
"admin.storages.capacityUnlimited": "Unlimited",
"admin.storages.capacityHint": "Maximum storage space. 0 means unlimited.",
"admin.storages.egressBilling": "Traffic Credits billing",
"admin.storages.configureEgressBilling": "Configure egress billing",
"admin.storages.egressBillingTitle": "Egress billing",
"admin.storages.egressBillingDescription": "Configure download traffic Credits billing for {{title}}.",
"admin.storages.billingTitle": "Limits and billing",
"admin.storages.billingDescription": "Bucket: {{bucket}}",
"admin.storages.egressBillingHint": "Charge workspace Credits for download traffic from this storage.",
"admin.storages.egressBillingBusinessOnly": "Traffic billing is available with a Business license.",
"admin.storages.egressBillingUnit": "Billing unit",
"admin.storages.egressBillingUnitPlaceholder": "100",
"admin.storages.egressBillingCredits": "Credits per unit",
"admin.storages.egressBillingCreditsPlaceholder": "1",
"admin.storages.egressBillingRate": "{{credits}} Credit per {{unit}}",
"admin.storages.egressBillingOff": "Off",
"admin.storages.egressBillingSaveSuccess": "Egress billing updated.",
"admin.storages.customHostPlaceholder": "Optional",
"admin.storages.billingSaveSuccess": "Storage limits and billing updated.",
"admin.storages.customHostPlaceholder": "cdn.example.com",
"settings.title": "Settings",
"settings.tabProfile": "Profile",
"settings.tabPassword": "Password",
+43 -9
View File
@@ -331,7 +331,6 @@
"admin.nav.users": "用户",
"admin.nav.settings": "设置",
"admin.nav.cloudStore": "存储套餐",
"admin.nav.email": "邮件",
"admin.nav.about": "关于",
"admin.overview.title": "管理后台概览",
"admin.overview.subtitle": "集中查看用户、存储、配额和邀请的关键运营状态。",
@@ -546,6 +545,7 @@
"admin.settings.captchaEnabled": "要求人机验证",
"admin.settings.captchaEnabledHint": "启用后,用户必须先完成所选人机验证才能使用密码认证。",
"admin.settings.captchaProvider": "验证提供商",
"admin.settings.captchaProviderPlaceholder": "选择验证提供商",
"admin.settings.captchaProviderTurnstile": "Cloudflare Turnstile",
"admin.settings.captchaProviderRecaptcha": "Google reCAPTCHA",
"admin.settings.captchaProviderHcaptcha": "hCaptcha",
@@ -561,6 +561,7 @@
"admin.settings.captchaMinScorePlaceholder": "0.5",
"admin.settings.captchaMinScoreHint": "可选的 Google reCAPTCHA v3 分数阈值,范围为 0 到 1。",
"admin.settings.captchaInvalid": "人机验证设置无效",
"admin.settings.quotaValuePlaceholder": "1",
"admin.settings.siteNamePlaceholder": "ZPan Workspace",
"admin.settings.siteNameHint": "保持简短且易识别,它会被复用到导航和浏览器标题等位置。",
"admin.settings.siteDescriptionPlaceholder": "为你的团队、客户或社区提供私有文件托管服务。",
@@ -607,9 +608,11 @@
"admin.settings.branding.themeTitle": "主题",
"admin.settings.branding.themeDescription": "选择内置 ZPan 主题,或为应用外壳设置自定义颜色。",
"admin.settings.branding.themeMode": "主题来源",
"admin.settings.branding.themeModePlaceholder": "选择主题来源",
"admin.settings.branding.themeModePreset": "内置主题",
"admin.settings.branding.themeModeCustom": "自定义颜色",
"admin.settings.branding.themePreset": "内置主题",
"admin.settings.branding.colorPlaceholder": "#000000",
"admin.settings.branding.themePrimary": "主色",
"admin.settings.branding.themePrimaryForeground": "主色文字",
"admin.settings.branding.themeCanvas": "画布强调色",
@@ -668,22 +671,28 @@
"admin.auth.addProvider": "添加提供商",
"admin.auth.addProviderTitle": "添加 OAuth 提供商",
"admin.auth.editProviderTitle": "编辑 OAuth 提供商",
"admin.auth.providerDrawerDescription": "提供商设置会立即保存用于管理员展示。登录运行时可能需要重启后才会使用变更。",
"admin.auth.providerDrawerDescription": "OAuth 登录提供商。",
"admin.auth.providerType": "提供商类型",
"admin.auth.providerBuiltin": "内置提供商",
"admin.auth.providerOidc": "自定义 OIDC",
"admin.auth.provider": "提供商",
"admin.auth.providerPlaceholder": "选择提供商",
"admin.auth.callbackUri": "Callback URI",
"admin.auth.callbackUriHint": "在 OAuth 提供商控制台中使用此重定向 URI。如果 BETTER_AUTH_URL 使用不同来源,请在该认证来源上保留相同路径。",
"admin.auth.copyCallbackUri": "复制 Callback URI",
"admin.auth.callbackUriCopied": "Callback URI 已复制",
"admin.auth.clientId": "Client ID",
"admin.auth.clientIdPlaceholder": "OAuth client ID",
"admin.auth.clientSecret": "Client Secret",
"admin.auth.clientSecretPlaceholder": "OAuth client secret",
"admin.auth.enabled": "启用",
"admin.auth.discoveryUrl": "Discovery URL",
"admin.auth.discoveryUrlPlaceholder": "https://accounts.example.com/.well-known/openid-configuration",
"admin.auth.scopes": "Scopes",
"admin.auth.scopesPlaceholder": "openid,email,profile",
"admin.auth.scopesHint": "逗号分隔的 scope 列表",
"admin.auth.providerId": "提供商 ID",
"admin.auth.providerIdPlaceholder": "company-sso",
"admin.auth.providerIdHint": "仅限小写字母、数字和连字符",
"admin.auth.providerSaved": "提供商已保存",
"admin.auth.providerDeleted": "提供商已删除",
@@ -697,17 +706,25 @@
"admin.auth.emailEnabled": "启用邮件",
"admin.auth.emailEnabledHint": "控制是否允许任何邮件提供商发送邮件。",
"admin.auth.emailProvider": "提供商类型",
"admin.auth.emailProviderPlaceholder": "选择邮件提供商",
"admin.auth.emailCloudflare": "Cloudflare 邮件",
"admin.auth.emailSmtp": "SMTP",
"admin.auth.emailHttp": "HTTP API",
"admin.auth.emailFrom": "发件地址",
"admin.auth.emailFromPlaceholder": "noreply@example.com",
"admin.auth.smtpHost": "主机",
"admin.auth.smtpHostPlaceholder": "smtp.example.com",
"admin.auth.smtpPort": "端口",
"admin.auth.smtpPortPlaceholder": "587",
"admin.auth.smtpUser": "用户名",
"admin.auth.smtpUserPlaceholder": "smtp-user",
"admin.auth.smtpPass": "密码",
"admin.auth.smtpPassPlaceholder": "SMTP password",
"admin.auth.smtpSecure": "安全连接 (TLS)",
"admin.auth.httpUrl": "API URL",
"admin.auth.httpUrlPlaceholder": "https://api.example.com/send-email",
"admin.auth.httpApiKey": "API Key",
"admin.auth.httpApiKeyPlaceholder": "HTTP API key",
"admin.auth.testEmail": "发送测试邮件",
"admin.auth.testEmailTo": "收件邮箱",
"admin.auth.testEmailSent": "测试邮件已发送",
@@ -720,14 +737,14 @@
"admin.storages.addTitle": "添加存储",
"admin.storages.editTitle": "编辑存储",
"admin.storages.deleteTitle": "删除存储",
"admin.storages.deleteConfirm": "删除存储 '{{title}}'?此操作无法撤销。",
"admin.storages.deleteConfirm": "删除存储 '{{bucket}}'?此操作无法撤销。",
"admin.storages.deleteHasFiles": "无法删除包含文件的存储。",
"admin.storages.created": "存储已创建",
"admin.storages.updated": "存储已更新",
"admin.storages.deleted": "存储已删除",
"admin.storages.noStorages": "暂无存储配置",
"admin.storages.colTitle": "标题",
"admin.storages.colBucket": "存储桶",
"admin.storages.colAccessKey": "Access Key",
"admin.storages.colEndpoint": "端点",
"admin.storages.colEgressBilling": "流量计费",
"admin.storages.colStatus": "状态",
@@ -738,38 +755,55 @@
"admin.storages.healthUntested": "未测试",
"admin.storages.healthTesting": "测试中...",
"admin.storages.testAction": "测试连接",
"admin.storages.testDialogTitle": "存储连接测试",
"admin.storages.testStepCreate": "创建临时上传对象",
"admin.storages.testStepUpload": "通过预签名 URL 上传",
"admin.storages.testStepCleanup": "清理测试对象",
"admin.storages.testStepDone": "已完成",
"admin.storages.testStepFailed": "失败",
"admin.storages.testStepRunning": "进行中",
"admin.storages.testStepPending": "未执行",
"admin.storages.testSuccess": "上传测试成功",
"admin.storages.testNoUploadUrl": "服务端未返回上传 URL。",
"admin.storages.testUploadFailed": "上传失败:{{detail}}",
"admin.storages.testCleanupFailed": "清理失败:{{detail}}",
"admin.storages.testCorsFailure": "浏览器无法访问预签名上传 URL,通常是存储桶 CORS 或端点可达性问题。",
"admin.storages.testCorsConfig": "存储桶 CORS 配置",
"admin.storages.testCorsCaveat": "为当前管理后台来源应用如下 CORS 配置,并同时检查端点、区域、存储桶名称和凭证。",
"admin.storages.fieldTitle": "标题",
"admin.storages.fieldBucket": "存储桶",
"admin.storages.bucketPlaceholder": "my-bucket",
"admin.storages.fieldEndpoint": "端点",
"admin.storages.endpointPlaceholder": "https://s3.amazonaws.com",
"admin.storages.fieldRegion": "区域",
"admin.storages.regionPlaceholder": "auto",
"admin.storages.fieldAccessKey": "Access Key",
"admin.storages.accessKeyPlaceholder": "AKIA...",
"admin.storages.fieldSecretKey": "Secret Key",
"admin.storages.secretKeyPlaceholder": "Secret access key",
"admin.storages.showSecretKey": "显示 Secret Key",
"admin.storages.hideSecretKey": "隐藏 Secret Key",
"admin.storages.fieldCustomHost": "自定义域名",
"admin.storages.customHostHint": "可选。生成此存储的文件访问 URL 时使用的公开域名。",
"admin.storages.fieldForcePathStyle": "路径样式",
"admin.storages.forcePathStyleHint": "使用路径样式寻址 (endpoint/bucket/key),关闭则使用虚拟主机样式 (bucket.endpoint/key)。",
"admin.storages.fieldCapacity": "可用空间",
"admin.storages.capacityPlaceholder": "0",
"admin.storages.capacityUnlimited": "不限制",
"admin.storages.capacityHint": "最大存储空间,0 表示不限制。",
"admin.storages.egressBilling": "流量 Credits 计费",
"admin.storages.configureEgressBilling": "配置流量计费",
"admin.storages.egressBillingTitle": "流量计费",
"admin.storages.egressBillingDescription": "配置 {{title}} 的下载流量 Credits 计费。",
"admin.storages.billingTitle": "容量与计费",
"admin.storages.billingDescription": "存储桶:{{bucket}}",
"admin.storages.egressBillingHint": "对此存储产生的下载流量扣除工作区 Credits。",
"admin.storages.egressBillingBusinessOnly": "流量计费需要 Business 授权。",
"admin.storages.egressBillingUnit": "计费单位",
"admin.storages.egressBillingUnitPlaceholder": "100",
"admin.storages.egressBillingCredits": "每单位 Credits",
"admin.storages.egressBillingCreditsPlaceholder": "1",
"admin.storages.egressBillingRate": "每 {{unit}} {{credits}} Credit",
"admin.storages.egressBillingOff": "关闭",
"admin.storages.egressBillingSaveSuccess": "流量计费已更新。",
"admin.storages.customHostPlaceholder": "可选",
"admin.storages.billingSaveSuccess": "容量与计费已更新。",
"admin.storages.customHostPlaceholder": "cdn.example.com",
"settings.title": "设置",
"settings.tabProfile": "基本信息",
"settings.tabPassword": "密码",
+6 -7
View File
@@ -1462,7 +1462,6 @@ describe('api', () => {
describe('createStorage', () => {
const validInput = {
title: 'minio',
bucket: 'files',
endpoint: 'https://minio.example.com',
region: 'us-east-1',
@@ -1473,7 +1472,7 @@ describe('api', () => {
}
it('posts storage data and returns created storage', async () => {
const storage = { id: 's1', title: 'minio', bucket: 'files' }
const storage = { id: 's1', bucket: 'files' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
const result = await createStorage(validInput)
@@ -1483,7 +1482,7 @@ describe('api', () => {
expect(url).toContain('/api/site/storages')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ title: 'minio', bucket: 'files', forcePathStyle: false })
expect(body).toMatchObject({ bucket: 'files', forcePathStyle: false })
const headers =
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
expect(headers.get('Content-Type')).toContain('application/json')
@@ -1517,23 +1516,23 @@ describe('api', () => {
describe('updateStorage', () => {
it('puts updated storage data and returns updated storage', async () => {
const storage = { id: 's1', title: 'updated-minio' }
const storage = { id: 's1', bucket: 'updated-files' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
const result = await updateStorage('s1', { title: 'updated-minio', forcePathStyle: false })
const result = await updateStorage('s1', { bucket: 'updated-files', forcePathStyle: false })
expect(result).toEqual(storage)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/site/storages/s1')
expect(init.method).toBe('PUT')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ title: 'updated-minio', forcePathStyle: false })
expect(body).toMatchObject({ bucket: 'updated-files', forcePathStyle: false })
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
await expect(updateStorage('s1', { title: 'x' })).rejects.toThrow('forbidden')
await expect(updateStorage('s1', { bucket: 'x' })).rejects.toThrow('forbidden')
})
})
@@ -5,7 +5,7 @@ import { Activity, Pencil, Settings2, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminSwitchField } from '@/components/admin/admin-form-drawer'
import { AdminPageHeader } from '@/components/admin/admin-page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -18,7 +18,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -366,18 +365,14 @@ function CreditBillingDrawer({
)
}
>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<Label htmlFor="remoteDownloadCreditBillingEnabled">{t('admin.downloaders.billingEnabled')}</Label>
<p className="text-xs text-muted-foreground">{t('admin.downloaders.billingEnabledHint')}</p>
</div>
<Switch
id="remoteDownloadCreditBillingEnabled"
disabled={!hasTrafficBilling}
checked={form.enabled}
onCheckedChange={(enabled) => onFormChange({ ...form, enabled: hasTrafficBilling && enabled })}
/>
</div>
<AdminSwitchField
id="remoteDownloadCreditBillingEnabled"
label={t('admin.downloaders.billingEnabled')}
description={t('admin.downloaders.billingEnabledHint')}
disabled={!hasTrafficBilling}
checked={form.enabled}
onCheckedChange={(enabled) => onFormChange({ ...form, enabled: hasTrafficBilling && enabled })}
/>
{!hasTrafficBilling && (
<p className="text-xs text-muted-foreground">{t('admin.downloaders.billingBusinessOnly')}</p>
)}
+1 -1
View File
@@ -122,7 +122,7 @@ function OverviewPage() {
{storages.slice(0, 4).map((storage) => (
<div key={storage.id} className="rounded-md border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium">{storage.title}</span>
<span className="truncate text-sm font-medium">{storage.bucket}</span>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{storage.status}
</span>
@@ -1,21 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { AdminPageHeader } from '@/components/admin/admin-page-header'
import { EmailConfigSection } from '@/components/admin/email-config-section'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/admin/settings/email')({
component: EmailSettingsPage,
beforeLoad: () => {
throw redirect({ to: '/admin/settings' })
},
})
function EmailSettingsPage() {
const { t } = useTranslation()
return (
<div className="space-y-6">
<AdminPageHeader title={t('admin.nav.email')} />
<div className="max-w-4xl">
<EmailConfigSection />
</div>
</div>
)
}
@@ -52,6 +52,10 @@ vi.mock('@/components/admin/branding-section', () => ({
BrandingSection: () => <section>branding</section>,
}))
vi.mock('@/components/admin/email-config-section', () => ({
EmailConfigSection: () => <section>email-config</section>,
}))
vi.mock('@/hooks/use-site-options', () => ({
siteOptionsQueryKey: ['system', 'options'],
useSiteOptions: () => siteOptionsState.current,
@@ -115,7 +119,10 @@ afterEach(() => {
describe('SettingsPage', () => {
function openSection(view: ReturnType<typeof renderSettingsPage>, title: string) {
const section = view.getByText(title).closest('[data-slot="card"]')
const section = view
.getAllByText(title)
.map((element) => element.closest('[data-settings-row]'))
.find(Boolean)
if (!section) throw new Error(`${title} section not found`)
fireEvent.click(within(section as HTMLElement).getByRole('button', { name: 'common.edit' }))
}
@@ -143,6 +150,12 @@ describe('SettingsPage', () => {
expect(toast.success).toHaveBeenCalledWith('admin.settings.saved')
})
it('shows email configuration on the settings page', async () => {
const view = renderSettingsPage()
expect(await view.findByText('email-config')).toBeTruthy()
})
it('discards identity edits when the drawer is cancelled', async () => {
const view = renderSettingsPage()
await view.findByText('admin.settings.identityTitle')
@@ -174,7 +187,7 @@ describe('SettingsPage', () => {
it('updates the default storage quota from the storage settings section', async () => {
const view = renderSettingsPage()
await view.findByText('admin.settings.storageSection')
await view.findAllByText('admin.settings.storageSection')
openSection(view, 'admin.settings.storageSection')
const quotaInput = await view.findByLabelText('admin.settings.defaultOrgQuota')
@@ -189,7 +202,7 @@ describe('SettingsPage', () => {
it('saves a newly typed quota value as bytes', async () => {
const view = renderSettingsPage()
await view.findByText('admin.settings.storageSection')
await view.findAllByText('admin.settings.storageSection')
openSection(view, 'admin.settings.storageSection')
const quotaInput = await view.findByLabelText('admin.settings.defaultOrgQuota')
@@ -205,7 +218,7 @@ describe('SettingsPage', () => {
it('saves the default team quota alongside the org quota', async () => {
const view = renderSettingsPage()
await view.findByText('admin.settings.storageSection')
await view.findAllByText('admin.settings.storageSection')
openSection(view, 'admin.settings.storageSection')
const teamQuotaInput = await view.findByLabelText('admin.settings.defaultTeamQuota')
@@ -220,7 +233,7 @@ describe('SettingsPage', () => {
it('saves captcha settings from the authentication protection section', async () => {
const view = renderSettingsPage()
await view.findByText('admin.settings.captchaTitle')
await view.findAllByText('admin.settings.captchaTitle')
openSection(view, 'admin.settings.captchaTitle')
fireEvent.change(await view.findByLabelText('admin.settings.captchaSiteKey'), {
+266 -199
View File
@@ -11,21 +11,22 @@ import {
import { SignupMode } from '@shared/constants'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Globe2, ShieldCheck } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Database, Globe2, ShieldCheck, UserPlus } from 'lucide-react'
import { type ComponentProps, type ReactNode, useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormField, AdminFormLabel } from '@/components/admin/admin-form-drawer'
import { AdminPageHeader } from '@/components/admin/admin-page-header'
import { BrandingSection } from '@/components/admin/branding-section'
import type { StorageQuotaUnit } from '@/components/admin/cloud-store-settings-section'
import { EmailConfigSection } from '@/components/admin/email-config-section'
import { ProBadge } from '@/components/ProBadge'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
@@ -65,19 +66,85 @@ const settingsSchema = z.object({
type SettingsFormValues = z.infer<typeof settingsSchema>
type SettingsDrawer = 'identity' | 'registration' | 'captcha' | 'storage' | null
type FieldControlProps = {
id?: string
'aria-invalid'?: boolean
'aria-describedby'?: string
'aria-required'?: boolean
}
function ProFeatureHeader({ title, description, tooltip }: { title: string; description: string; tooltip: string }) {
function SettingsStatusBadge({ enabled, label }: { enabled: boolean; label: string }) {
return <Badge variant={enabled ? 'default' : 'secondary'}>{label}</Badge>
}
function SettingsItemCard({
icon,
title,
description,
status,
details,
proTooltip,
editLabel,
onEdit,
}: {
icon: ReactNode
title: ReactNode
description: ReactNode
status: ReactNode
details?: ReactNode
proTooltip?: string
editLabel: string
onEdit: () => void
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<CardTitle>{title}</CardTitle>
<ProBadge tooltip={tooltip} />
</div>
<CardDescription>{description}</CardDescription>
</div>
<Card data-settings-row className="rounded-lg border-border/70 py-0 shadow-xs">
<CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted text-muted-foreground">
{icon}
</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-sm leading-5">{title}</CardTitle>
{proTooltip && <ProBadge tooltip={proTooltip} />}
</div>
<CardDescription className="max-w-2xl leading-5">{description}</CardDescription>
{details && <div className="text-sm text-muted-foreground">{details}</div>}
</div>
</div>
<div className="flex shrink-0 items-center justify-between gap-3 sm:justify-end">
{status}
<Button size="sm" variant="outline" onClick={onEdit}>
{editLabel}
</Button>
</div>
</CardContent>
</Card>
)
}
function SettingsSection({ title, children }: { title: ReactNode; children: ReactNode }) {
return (
<section className="space-y-2">
<h3 className="px-1 text-muted-foreground text-xs font-medium uppercase">{title}</h3>
<div className="grid gap-2">{children}</div>
</section>
)
}
function CaptchaProviderLabel({ provider }: { provider: CaptchaProvider }) {
switch (provider) {
case 'google-recaptcha':
return 'Google reCAPTCHA'
case 'hcaptcha':
return 'hCaptcha'
case 'captchafox':
return 'CaptchaFox'
case 'cloudflare-turnstile':
return 'Cloudflare Turnstile'
}
}
export function SettingsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -269,120 +336,101 @@ export function SettingsPage() {
}
return (
<div className="space-y-6">
<div className="space-y-5">
<AdminPageHeader title={t('admin.settings.title')} description={t('admin.settings.subtitle')} />
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-border/60">
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-border/60 bg-primary/10 p-2 text-primary">
<Globe2 className="h-5 w-5" />
</div>
<ProFeatureHeader
title={t('admin.settings.identityTitle')}
description={t('admin.settings.identityDescription')}
tooltip={t('admin.settings.identityProTooltip')}
/>
</div>
<Button size="sm" variant="outline" onClick={() => setSettingsDrawer('identity')}>
{t('common.edit')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p className="font-medium">{siteName}</p>
<p className="text-muted-foreground">{siteDescription || t('admin.settings.previewFallback')}</p>
<p className="text-xs text-muted-foreground">
{sitePublicOrigin || t('admin.settings.sitePublicOriginPlaceholder')}
</p>
</CardContent>
</Card>
<div className="space-y-5">
<SettingsSection title={t('admin.settings.siteSection')}>
<SettingsItemCard
icon={<Globe2 className="size-4" />}
title={t('admin.settings.identityTitle')}
description={t('admin.settings.identityDescription')}
details={
<span>
{siteName} · {sitePublicOrigin || t('admin.settings.sitePublicOriginPlaceholder')}
</span>
}
status={
<SettingsStatusBadge
enabled={hasWhiteLabel}
label={hasWhiteLabel ? t('admin.auth.enabled') : t('common.disabled')}
/>
}
proTooltip={t('admin.settings.identityProTooltip')}
editLabel={t('common.edit')}
onEdit={() => setSettingsDrawer('identity')}
/>
<Card className="border-border/60">
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-border/60 bg-amber-500/10 p-2 text-amber-600">
<Globe2 className="h-5 w-5" />
</div>
<ProFeatureHeader
title={t('admin.settings.registrationTitle')}
description={t('admin.settings.registrationDescription')}
tooltip={t('admin.settings.registrationUpgradeHint')}
/>
</div>
<Button size="sm" variant="outline" onClick={() => setSettingsDrawer('registration')}>
{t('common.edit')}
</Button>
</div>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{savedRegistrationsEnabled
? t('admin.settings.registrationHintOpen')
: t('admin.settings.registrationHintClosed')}
</CardContent>
</Card>
<BrandingSection />
<Card className="border-border/60">
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-border/60 bg-emerald-500/10 p-2 text-emerald-600">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="space-y-1">
<CardTitle>{t('admin.settings.captchaTitle')}</CardTitle>
<CardDescription>{t('admin.settings.captchaDescription')}</CardDescription>
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setSettingsDrawer('captcha')}>
{t('common.edit')}
</Button>
</div>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{captchaEnabled ? t('admin.settings.captchaProvider') : t('common.disabled')}
</CardContent>
</Card>
<SettingsItemCard
icon={<UserPlus className="size-4" />}
title={t('admin.settings.registrationTitle')}
description={t('admin.settings.registrationDescription')}
details={
savedRegistrationsEnabled
? t('admin.settings.registrationHintOpen')
: t('admin.settings.registrationHintClosed')
}
status={
<SettingsStatusBadge
enabled={savedRegistrationsEnabled}
label={savedRegistrationsEnabled ? t('admin.auth.enabled') : t('common.disabled')}
/>
}
proTooltip={t('admin.settings.registrationUpgradeHint')}
editLabel={t('common.edit')}
onEdit={() => setSettingsDrawer('registration')}
/>
<Card className="border-border/60">
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="rounded-lg border border-border/60 bg-emerald-500/10 p-2 text-emerald-600">
<Globe2 className="h-5 w-5" />
</div>
<div className="space-y-1">
<CardTitle>{t('admin.settings.storageSection')}</CardTitle>
<CardDescription>{t('admin.settings.quotaDescription')}</CardDescription>
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setSettingsDrawer('storage')}>
{t('common.edit')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>
{t('admin.settings.defaultOrgQuota')}: {savedQuota.value} {savedQuota.unit}
</p>
<p>
{t('admin.settings.defaultTeamQuota')}: {savedTeamQuota.value} {savedTeamQuota.unit}
</p>
</CardContent>
</Card>
<SettingsItemCard
icon={<ShieldCheck className="size-4" />}
title={t('admin.settings.captchaTitle')}
description={t('admin.settings.captchaDescription')}
details={captchaEnabled ? <CaptchaProviderLabel provider={captchaProvider} /> : t('common.disabled')}
status={
<SettingsStatusBadge
enabled={captchaEnabled}
label={captchaEnabled ? t('admin.auth.enabled') : t('common.disabled')}
/>
}
editLabel={t('common.edit')}
onEdit={() => setSettingsDrawer('captcha')}
/>
</SettingsSection>
<SettingsSection title={t('admin.settings.storageSection')}>
<SettingsItemCard
icon={<Database className="size-4" />}
title={t('admin.settings.storageSection')}
description={t('admin.settings.quotaDescription')}
details={
<span>
{t('admin.settings.defaultOrgQuota')}: {savedQuota.value} {savedQuota.unit} ·{' '}
{t('admin.settings.defaultTeamQuota')}: {savedTeamQuota.value} {savedTeamQuota.unit}
</span>
}
status={
<Badge variant="secondary">
{savedQuota.value} {savedQuota.unit}
</Badge>
}
editLabel={t('common.edit')}
onEdit={() => setSettingsDrawer('storage')}
/>
</SettingsSection>
<SettingsSection title={t('admin.auth.emailSection')}>
<EmailConfigSection />
</SettingsSection>
</div>
<BrandingSection />
<AdminFormDrawer
open={settingsDrawer === 'identity'}
onOpenChange={(open) => !open && closeSettingsDrawer()}
title={t('admin.settings.identityTitle')}
description={t('admin.settings.identityDescription')}
bodyClassName="grid gap-5"
bodyClassName="grid auto-rows-min content-start gap-4"
footer={
<>
<Button type="button" variant="outline" onClick={() => closeSettingsDrawer()}>
@@ -401,7 +449,8 @@ export function SettingsPage() {
<AdminFormField
id="siteName"
label={t('admin.settings.siteName')}
description={t('admin.settings.siteNameHint')}
help={t('admin.settings.siteNameHint')}
required
error={form.formState.errors.siteName?.message}
className={!hasWhiteLabel ? 'opacity-60' : undefined}
>
@@ -417,7 +466,7 @@ export function SettingsPage() {
<AdminFormField
id="siteDescription"
label={t('admin.settings.siteDescription')}
description={t('admin.settings.siteDescriptionHint')}
help={t('admin.settings.siteDescriptionHint')}
error={form.formState.errors.siteDescription?.message}
className={!hasWhiteLabel ? 'opacity-60' : undefined}
>
@@ -434,7 +483,7 @@ export function SettingsPage() {
<AdminFormField
id="sitePublicOrigin"
label={t('admin.settings.sitePublicOrigin')}
description={t('admin.settings.sitePublicOriginHint')}
help={t('admin.settings.sitePublicOriginHint')}
error={form.formState.errors.sitePublicOrigin?.message}
>
<Input placeholder={t('admin.settings.sitePublicOriginPlaceholder')} {...form.register('sitePublicOrigin')} />
@@ -446,21 +495,24 @@ export function SettingsPage() {
onOpenChange={(open) => !open && closeSettingsDrawer()}
title={t('admin.settings.registrationTitle')}
description={t('admin.settings.registrationDescription')}
bodyClassName="grid auto-rows-min content-start gap-4"
footer={
<Button type="button" variant="outline" onClick={() => closeSettingsDrawer()}>
{t('common.close')}
</Button>
}
>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="registrationsEnabled">{t('admin.settings.registrationLabel')}</Label>
<p className="text-xs leading-5 text-muted-foreground">
{registrationsEnabled
<div className="flex items-center justify-between gap-4">
<AdminFormLabel
htmlFor="registrationsEnabled"
help={
registrationsEnabled
? t('admin.settings.registrationHintOpen')
: t('admin.settings.registrationHintClosed')}
</p>
</div>
: t('admin.settings.registrationHintClosed')
}
>
{t('admin.settings.registrationLabel')}
</AdminFormLabel>
<Switch
id="registrationsEnabled"
checked={registrationsEnabled}
@@ -471,6 +523,9 @@ export function SettingsPage() {
}}
/>
</div>
{!hasOpenRegistration && (
<p className="text-xs text-muted-foreground">{t('admin.settings.registrationUpgradeHint')}</p>
)}
</AdminFormDrawer>
<AdminFormDrawer
@@ -478,7 +533,7 @@ export function SettingsPage() {
onOpenChange={(open) => !open && closeSettingsDrawer()}
title={t('admin.settings.captchaTitle')}
description={t('admin.settings.captchaDescription')}
bodyClassName="grid gap-5"
bodyClassName="grid auto-rows-min content-start gap-4"
footer={
<>
<Button type="button" variant="outline" onClick={() => closeSettingsDrawer()}>
@@ -490,11 +545,10 @@ export function SettingsPage() {
</>
}
>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="captchaEnabled">{t('admin.settings.captchaEnabled')}</Label>
<p className="text-xs leading-5 text-muted-foreground">{t('admin.settings.captchaEnabledHint')}</p>
</div>
<div className="flex items-center justify-between gap-4">
<AdminFormLabel htmlFor="captchaEnabled" help={t('admin.settings.captchaEnabledHint')}>
{t('admin.settings.captchaEnabled')}
</AdminFormLabel>
<Switch
id="captchaEnabled"
checked={captchaProtectionEnabled}
@@ -506,7 +560,8 @@ export function SettingsPage() {
<AdminFormField
id="captchaProvider"
label={t('admin.settings.captchaProvider')}
description={t('admin.settings.captchaProviderHint')}
help={t('admin.settings.captchaProviderHint')}
required
>
<Select
value={selectedCaptchaProvider}
@@ -515,7 +570,7 @@ export function SettingsPage() {
}
>
<SelectTrigger id="captchaProvider">
<SelectValue />
<SelectValue placeholder={t('admin.settings.captchaProviderPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="cloudflare-turnstile">{t('admin.settings.captchaProviderTurnstile')}</SelectItem>
@@ -526,32 +581,32 @@ export function SettingsPage() {
</Select>
</AdminFormField>
<div className="grid gap-4 md:grid-cols-2">
<AdminFormField
id="captchaSiteKey"
label={t('admin.settings.captchaSiteKey')}
description={t('admin.settings.captchaSiteKeyHint')}
>
<Input placeholder={t('admin.settings.captchaSiteKeyPlaceholder')} {...form.register('captchaSiteKey')} />
</AdminFormField>
<AdminFormField
id="captchaSecretKey"
label={t('admin.settings.captchaSecretKey')}
description={t('admin.settings.captchaSecretKeyHint')}
>
<Input
type="password"
placeholder={t('admin.settings.captchaSecretKeyPlaceholder')}
{...form.register('captchaSecretKey')}
/>
</AdminFormField>
</div>
<AdminFormField
id="captchaSiteKey"
label={t('admin.settings.captchaSiteKey')}
help={t('admin.settings.captchaSiteKeyHint')}
required={captchaProtectionEnabled}
>
<Input placeholder={t('admin.settings.captchaSiteKeyPlaceholder')} {...form.register('captchaSiteKey')} />
</AdminFormField>
<AdminFormField
id="captchaSecretKey"
label={t('admin.settings.captchaSecretKey')}
help={t('admin.settings.captchaSecretKeyHint')}
required={captchaProtectionEnabled}
>
<Input
type="password"
placeholder={t('admin.settings.captchaSecretKeyPlaceholder')}
{...form.register('captchaSecretKey')}
/>
</AdminFormField>
{selectedCaptchaProvider === 'google-recaptcha' && (
<AdminFormField
id="captchaMinScore"
label={t('admin.settings.captchaMinScore')}
description={t('admin.settings.captchaMinScoreHint')}
help={t('admin.settings.captchaMinScoreHint')}
>
<Input
inputMode="decimal"
@@ -567,7 +622,7 @@ export function SettingsPage() {
onOpenChange={(open) => !open && closeSettingsDrawer()}
title={t('admin.settings.storageSection')}
description={t('admin.settings.quotaDescription')}
bodyClassName="grid gap-5"
bodyClassName="grid auto-rows-min content-start gap-4"
footer={
<>
<Button type="button" variant="outline" onClick={() => closeSettingsDrawer()}>
@@ -582,64 +637,76 @@ export function SettingsPage() {
<AdminFormField
id="quotaValue"
label={t('admin.settings.defaultOrgQuota')}
description={t('admin.settings.defaultOrgQuotaHint')}
help={t('admin.settings.defaultOrgQuotaHint')}
required
error={form.formState.errors.quotaValue?.message}
>
{(controlProps) => (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
step={1}
className="flex-1"
{...controlProps}
{...form.register('quotaValue', { valueAsNumber: true })}
/>
<Select value={quotaUnit} onValueChange={(unit) => form.setValue('quotaUnit', unit as StorageQuotaUnit)}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
</SelectContent>
</Select>
</div>
<QuotaAmountInput
controlProps={controlProps}
inputProps={form.register('quotaValue', { valueAsNumber: true })}
placeholder={t('admin.settings.quotaValuePlaceholder')}
unit={quotaUnit}
onUnitChange={(unit) => form.setValue('quotaUnit', unit)}
/>
)}
</AdminFormField>
<AdminFormField
id="teamQuotaValue"
label={t('admin.settings.defaultTeamQuota')}
description={t('admin.settings.defaultTeamQuotaHint')}
help={t('admin.settings.defaultTeamQuotaHint')}
required
error={form.formState.errors.teamQuotaValue?.message}
>
{(controlProps) => (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
step={1}
className="flex-1"
{...controlProps}
{...form.register('teamQuotaValue', { valueAsNumber: true })}
/>
<Select
value={teamQuotaUnit}
onValueChange={(unit) => form.setValue('teamQuotaUnit', unit as StorageQuotaUnit)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
</SelectContent>
</Select>
</div>
<QuotaAmountInput
controlProps={controlProps}
inputProps={form.register('teamQuotaValue', { valueAsNumber: true })}
placeholder={t('admin.settings.quotaValuePlaceholder')}
unit={teamQuotaUnit}
onUnitChange={(unit) => form.setValue('teamQuotaUnit', unit)}
/>
)}
</AdminFormField>
</AdminFormDrawer>
</div>
)
}
function QuotaAmountInput({
controlProps,
inputProps,
placeholder,
unit,
onUnitChange,
}: {
controlProps: FieldControlProps
inputProps: ComponentProps<typeof Input>
placeholder: string
unit: StorageQuotaUnit
onUnitChange: (unit: StorageQuotaUnit) => void
}) {
return (
<div className="flex h-9 w-48 items-center overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30">
<Input
{...inputProps}
{...controlProps}
type="number"
min={1}
step={1}
placeholder={placeholder}
className="h-8 flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0"
/>
<Select value={unit} onValueChange={(nextUnit) => onUnitChange(nextUnit as StorageQuotaUnit)}>
<SelectTrigger className="h-8 w-20 rounded-none border-0 border-l bg-transparent px-2 shadow-none focus-visible:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
</SelectContent>
</Select>
</div>
)
}
@@ -8,6 +8,7 @@ import {
type CreateObjectResult,
createObject,
listStorages,
updateStorage,
updateStorageEgressBilling,
} from '@/lib/api'
import { corsJsonForOrigin, StoragesPage } from './index'
@@ -45,12 +46,12 @@ vi.mock('@/lib/api', () => ({
abortObjectUpload: vi.fn(),
createObject: vi.fn(),
listStorages: vi.fn(),
updateStorage: vi.fn(),
updateStorageEgressBilling: vi.fn(),
}))
const storage: Storage = {
id: 'storage-1',
title: 'Primary storage',
bucket: 'bucket',
endpoint: 'https://s3.example.com',
region: 'auto',
@@ -133,6 +134,14 @@ describe('admin storages CORS guidance', () => {
})
describe('StoragesPage connection test action', () => {
it('shows the storage access key in the list', async () => {
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
const view = renderStoragesPage()
expect(await view.findByText('access-key')).toBeTruthy()
})
it('creates a storage-targeted object, PUTs to S3, renders success, and cleans up strictly', async () => {
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
vi.mocked(createObject).mockResolvedValue(uploadDraft)
@@ -143,6 +152,10 @@ describe('StoragesPage connection test action', () => {
const view = renderStoragesPage()
fireEvent.click(await view.findByTitle('admin.storages.testAction'))
await view.findByText('admin.storages.testDialogTitle')
expect(screen.getByText('admin.storages.testStepCreate')).toBeTruthy()
expect(screen.getByText('admin.storages.testStepUpload')).toBeTruthy()
expect(screen.getByText('admin.storages.testStepCleanup')).toBeTruthy()
await waitFor(() =>
expect(createObject).toHaveBeenCalledWith(
expect.objectContaining({
@@ -176,28 +189,34 @@ describe('StoragesPage connection test action', () => {
fireEvent.click(await view.findByTitle('admin.storages.testAction'))
await view.findByText('admin.storages.testCorsFailure')
expect(view.container.textContent).toContain('admin.storages.testCorsCaveat')
expect(view.container.textContent).toContain(window.location.origin)
expect(view.container.textContent).toContain('"AllowedMethods": [')
expect(view.container.textContent).toContain('"GET"')
expect(view.container.textContent).toContain('"PUT"')
expect(view.container.textContent).toContain('"POST"')
expect(view.container.textContent).toContain('"HEAD"')
expect(view.container.textContent).toContain('"MaxAgeSeconds": 3600')
expect(screen.getByTestId('storage-test-step-creating').dataset.state).toBe('done')
expect(screen.getByTestId('storage-test-step-uploading').dataset.state).toBe('failed')
expect(screen.getByTestId('storage-test-step-cleanup').dataset.state).toBe('pending')
expect(document.body.textContent).toContain('admin.storages.testCorsCaveat')
expect(document.body.textContent).toContain(window.location.origin)
expect(document.body.textContent).toContain('"AllowedMethods": [')
expect(document.body.textContent).toContain('"GET"')
expect(document.body.textContent).toContain('"PUT"')
expect(document.body.textContent).toContain('"POST"')
expect(document.body.textContent).toContain('"HEAD"')
expect(document.body.textContent).toContain('"MaxAgeSeconds": 3600')
expect(abortObjectUpload).toHaveBeenCalledWith('object-1', 'session-1', { strictStorageCleanup: true })
})
it('opens egress billing from the row action and saves through the dedicated wrapper', async () => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.mocked(listStorages).mockResolvedValue({ items: [{ ...storage, egressCreditBillingEnabled: true }], total: 1 })
vi.mocked(updateStorage).mockResolvedValue(storage)
vi.mocked(updateStorageEgressBilling).mockResolvedValue(storage)
const view = renderStoragesPage()
fireEvent.click(await view.findByTitle('admin.storages.configureEgressBilling'))
await view.findByText('admin.storages.egressBillingTitle')
await view.findByText('admin.storages.billingTitle')
fireEvent.change(screen.getByLabelText('admin.storages.fieldCapacity'), { target: { value: '2' } })
fireEvent.change(screen.getByLabelText('admin.storages.egressBillingCredits'), { target: { value: '4' } })
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(updateStorage).toHaveBeenCalledWith('storage-1', { capacity: 2 * 1024 * 1024 * 1024 }))
await waitFor(() =>
expect(updateStorageEgressBilling).toHaveBeenCalledWith('storage-1', {
enabled: true,
@@ -211,15 +230,18 @@ describe('StoragesPage connection test action', () => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
mockHasFeature.mockImplementation((feature) => feature !== 'quota_store')
vi.mocked(listStorages).mockResolvedValue({ items: [{ ...storage, egressCreditBillingEnabled: true }], total: 1 })
vi.mocked(updateStorage).mockResolvedValue(storage)
const view = renderStoragesPage()
fireEvent.click(await view.findByTitle('admin.storages.configureEgressBilling'))
await view.findByText('admin.storages.egressBillingBusinessOnly')
expect(screen.queryByRole('button', { name: 'common.save' })).toBeNull()
expect(screen.getByLabelText('admin.storages.egressBillingUnit')).toHaveProperty('disabled', true)
expect(screen.getByLabelText('admin.storages.egressBillingCredits')).toHaveProperty('disabled', true)
expect(screen.getAllByRole('button', { name: 'common.close' })).toHaveLength(2)
fireEvent.change(screen.getByLabelText('admin.storages.fieldCapacity'), { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(updateStorage).toHaveBeenCalledWith('storage-1', { capacity: 3 * 1024 * 1024 * 1024 }))
expect(updateStorageEgressBilling).not.toHaveBeenCalled()
})
})
+432 -146
View File
@@ -16,18 +16,33 @@ import {
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer'
import { AdminFormDrawer, AdminFormLabel } from '@/components/admin/admin-form-drawer'
import { AdminPageHeader } from '@/components/admin/admin-page-header'
import { DeleteStorageDialog } from '@/components/admin/delete-storage-dialog'
import { StorageFormDrawer } from '@/components/admin/storage-form-drawer'
import { ProBadge } from '@/components/ProBadge'
import { UpgradeHint } from '@/components/UpgradeHint'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useEntitlement } from '@/hooks/useEntitlement'
import { ApiError, abortObjectUpload, createObject, listStorages, updateStorageEgressBilling } from '@/lib/api'
import {
ApiError,
abortObjectUpload,
createObject,
listStorages,
updateStorage,
updateStorageEgressBilling,
} from '@/lib/api'
import { formatSize } from '@/lib/format'
export const Route = createFileRoute('/_authenticated/admin/storages/')({
@@ -40,15 +55,20 @@ type StorageHealth =
| { status: 'success'; message: string }
| { status: 'error'; message: string }
| { status: 'cors'; message: string; corsJson: string }
type StorageTestStep = 'creating' | 'uploading' | 'cleanup'
type StorageTestPosition = StorageTestStep | 'done'
type StorageTestStepState = 'done' | 'failed' | 'active' | 'pending'
const TEST_CONTENT = 'zpan storage connection test\n'
const CREDIT_UNITS = { MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 } as const
const DATA_UNITS = { MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 } as const
type CreditUnit = keyof typeof CREDIT_UNITS
type EgressBillingForm = {
type DataUnit = keyof typeof DATA_UNITS
type BillingForm = {
capacityValue: string
capacityUnit: DataUnit
enabled: boolean
unitValue: string
unit: CreditUnit
unit: DataUnit
credits: string
}
@@ -81,9 +101,12 @@ export function StoragesPage() {
const [formOpen, setFormOpen] = useState(false)
const [editingStorage, setEditingStorage] = useState<Storage | null>(null)
const [billingTarget, setBillingTarget] = useState<Storage | null>(null)
const [billingForm, setBillingForm] = useState<EgressBillingForm>(emptyEgressBillingForm())
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [healthByStorage, setHealthByStorage] = useState<Record<string, StorageHealth>>({})
const [billingForm, setBillingForm] = useState<BillingForm>(emptyBillingForm())
const [deleteTarget, setDeleteTarget] = useState<{ id: string; bucket: string } | null>(null)
const [testTarget, setTestTarget] = useState<Storage | null>(null)
const [testHealth, setTestHealth] = useState<StorageHealth>({ status: 'idle' })
const [testStep, setTestStep] = useState<StorageTestPosition>('done')
const [testFailedStep, setTestFailedStep] = useState<StorageTestStep | null>(null)
const storagesQuery = useQuery({
queryKey: ['admin', 'storages'],
@@ -95,15 +118,16 @@ export function StoragesPage() {
const hasTrafficBilling = hasFeature('quota_store')
const billingMutation = useMutation({
mutationFn: ({ storage, form }: { storage: Storage; form: EgressBillingForm }) =>
updateStorageEgressBilling(
storage.id,
egressBillingPayload(hasTrafficBilling ? form : { ...form, enabled: false }),
),
mutationFn: async ({ storage, form }: { storage: Storage; form: BillingForm }) => {
await updateStorage(storage.id, { capacity: capacityPayload(form) })
if (hasTrafficBilling) {
await updateStorageEgressBilling(storage.id, egressBillingPayload(form))
}
},
onSuccess: () => {
setBillingTarget(null)
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
toast.success(t('admin.storages.egressBillingSaveSuccess'))
toast.success(t('admin.storages.billingSaveSuccess'))
},
onError: (err) => toast.error(err.message),
})
@@ -121,7 +145,7 @@ export function StoragesPage() {
function handleConfigureBilling(storage: Storage) {
setBillingTarget(storage)
setBillingForm(egressBillingFormFromStorage(storage))
setBillingForm(billingFormFromStorage(storage))
}
function handleFormOpenChange(open: boolean) {
@@ -130,9 +154,18 @@ export function StoragesPage() {
}
async function handleTest(storage: Storage) {
setHealthByStorage((current) => ({ ...current, [storage.id]: { status: 'testing' } }))
setTestTarget(storage)
setTestHealth({ status: 'testing' })
setTestStep('creating')
setTestFailedStep(null)
let draft: { id: string; upload?: { sessionId: string; urls: string[] } } | null = null
let result: StorageHealth | null = null
let currentStep: StorageTestStep = 'creating'
let failedStep: StorageTestStep | null = null
const setCurrentStep = (step: StorageTestStep) => {
currentStep = step
setTestStep(step)
}
try {
const blob = new Blob([TEST_CONTENT], { type: 'text/plain' })
@@ -145,6 +178,8 @@ export function StoragesPage() {
storageId: storage.id,
})
const upload = draft.upload
setCurrentStep('uploading')
if (!upload?.urls[0]) throw new Error(t('admin.storages.testNoUploadUrl'))
let uploadResponse: Response
@@ -160,6 +195,7 @@ export function StoragesPage() {
message: t('admin.storages.testCorsFailure'),
corsJson: corsJsonForOrigin(window.location.origin),
}
failedStep = 'uploading'
return
}
@@ -171,13 +207,16 @@ export function StoragesPage() {
result = { status: 'success', message: t('admin.storages.testSuccess') }
} catch (error) {
failedStep = currentStep
result = { status: 'error', message: readableError(error) }
} finally {
if (draft?.upload) {
if (result?.status === 'success') setCurrentStep('cleanup')
try {
await abortObjectUpload(draft.id, draft.upload.sessionId, { strictStorageCleanup: true })
} catch (cleanupError) {
const cleanupMessage = t('admin.storages.testCleanupFailed', { detail: readableError(cleanupError) })
if (result?.status === 'success') failedStep = 'cleanup'
result =
result?.status === 'success'
? { status: 'error', message: cleanupMessage }
@@ -186,7 +225,12 @@ export function StoragesPage() {
: { status: 'error', message: cleanupMessage }
}
}
setHealthByStorage((current) => ({ ...current, [storage.id]: result ?? { status: 'idle' } }))
const finalResult = result ?? { status: 'idle' as const }
setTestFailedStep(
finalResult.status === 'success' || finalResult.status === 'idle' ? null : (failedStep ?? currentStep),
)
setTestStep(finalResult.status === 'success' ? 'done' : (failedStep ?? currentStep))
setTestHealth(result ?? { status: 'idle' })
}
}
@@ -216,8 +260,8 @@ export function StoragesPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colTitle')}</th>
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">{t('admin.storages.colBucket')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colBucket')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colAccessKey')}</th>
<th className="hidden max-w-48 px-4 py-3 text-left font-medium lg:table-cell">
{t('admin.storages.colEndpoint')}
</th>
@@ -225,7 +269,6 @@ export function StoragesPage() {
{t('admin.storages.colEgressBilling')}
</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colStatus')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colHealth')}</th>
<th className="px-4 py-3 text-right font-medium">{t('admin.storages.colActions')}</th>
</tr>
</thead>
@@ -235,16 +278,16 @@ export function StoragesPage() {
key={storage.id}
storage={storage}
hasTrafficBilling={hasTrafficBilling}
health={healthByStorage[storage.id] ?? { status: 'idle' }}
testing={testTarget?.id === storage.id && testHealth.status === 'testing'}
onTest={() => handleTest(storage)}
onEdit={() => handleEdit(storage)}
onConfigureBilling={() => handleConfigureBilling(storage)}
onDelete={() => setDeleteTarget({ id: storage.id, title: storage.title })}
onDelete={() => setDeleteTarget({ id: storage.id, bucket: storage.bucket })}
/>
))}
{storages.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-muted-foreground">
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Database className="h-10 w-10" />
<p>{t('admin.storages.noStorages')}</p>
@@ -269,6 +312,17 @@ export function StoragesPage() {
onConfirm={() => billingTarget && billingMutation.mutate({ storage: billingTarget, form: billingForm })}
/>
<StorageTestDialog
storage={testTarget}
health={testHealth}
step={testStep}
failedStep={testFailedStep}
open={testTarget !== null}
onOpenChange={(open) => {
if (!open) setTestTarget(null)
}}
/>
<DeleteStorageDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
@@ -281,7 +335,7 @@ export function StoragesPage() {
function StorageTableRow({
storage,
hasTrafficBilling,
health,
testing,
onTest,
onEdit,
onConfigureBilling,
@@ -289,7 +343,7 @@ function StorageTableRow({
}: {
storage: Storage
hasTrafficBilling: boolean
health: StorageHealth
testing: boolean
onTest: () => void
onEdit: () => void
onConfigureBilling: () => void
@@ -303,8 +357,10 @@ function StorageTableRow({
return (
<tr className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{storage.title}</td>
<td className="hidden px-4 py-3 text-muted-foreground md:table-cell">{storage.bucket}</td>
<td className="px-4 py-3 font-medium">{storage.bucket}</td>
<td className="max-w-44 truncate px-4 py-3 font-mono text-muted-foreground text-xs" title={storage.accessKey}>
{storage.accessKey}
</td>
<td className="hidden max-w-48 truncate px-4 py-3 text-muted-foreground lg:table-cell">{storage.endpoint}</td>
<td className="hidden px-4 py-3 text-muted-foreground lg:table-cell">
{hasTrafficBilling && storage.egressCreditBillingEnabled
@@ -319,9 +375,6 @@ function StorageTableRow({
{isActive ? t('admin.storages.statusActive') : t('admin.storages.statusInactive')}
</span>
</td>
<td className="min-w-64 px-4 py-3">
<StorageHealthView health={health} />
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button
@@ -329,9 +382,9 @@ function StorageTableRow({
size="icon-xs"
onClick={onTest}
title={t('admin.storages.testAction')}
disabled={health.status === 'testing'}
disabled={testing}
>
{health.status === 'testing' ? <Loader2 className="animate-spin" /> : <TestTube2 />}
{testing ? <Loader2 className="animate-spin" /> : <TestTube2 />}
</Button>
<Button variant="ghost" size="icon-xs" onClick={onEdit} title={t('common.edit')}>
<Pencil />
@@ -353,6 +406,186 @@ function StorageTableRow({
)
}
function StorageTestDialog({
storage,
health,
step,
failedStep,
open,
onOpenChange,
}: {
storage: Storage | null
health: StorageHealth
step: StorageTestPosition
failedStep: StorageTestStep | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const steps: Array<{ key: StorageTestStep; label: string }> = [
{ key: 'creating', label: t('admin.storages.testStepCreate') },
{ key: 'uploading', label: t('admin.storages.testStepUpload') },
{ key: 'cleanup', label: t('admin.storages.testStepCleanup') },
]
const activeIndex = step === 'done' ? steps.length : steps.findIndex((item) => item.key === step)
const failedIndex = failedStep ? steps.findIndex((item) => item.key === failedStep) : -1
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t('admin.storages.testDialogTitle')}</DialogTitle>
<DialogDescription>{storage?.bucket ?? ''}</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<StorageTestResult health={health} />
<div className="overflow-hidden rounded-md border">
{steps.map((item, index) => {
const state = getStorageTestStepState({
index,
activeIndex,
failedIndex,
health,
})
return (
<div
key={item.key}
data-testid={`storage-test-step-${item.key}`}
data-state={state}
className={`flex items-center justify-between gap-3 border-b px-3 py-2.5 text-sm last:border-b-0 ${storageTestStepClassName(
state,
)}`}
>
<div className="flex min-w-0 items-center gap-2">
<StorageTestStepIcon state={state} />
<span className="truncate">{item.label}</span>
</div>
<span className="shrink-0 text-xs">{storageTestStepStatusLabel(t, state)}</span>
</div>
)
})}
</div>
{health.status === 'cors' && (
<details className="rounded-md border bg-muted/20 p-3">
<summary className="cursor-pointer text-sm font-medium">{t('admin.storages.testCorsConfig')}</summary>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{t('admin.storages.testCorsCaveat')}</p>
<pre className="mt-3 max-h-56 overflow-auto rounded-md bg-background p-3 font-mono text-[11px] leading-4 text-muted-foreground">
{health.corsJson}
</pre>
</details>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={health.status === 'testing'}
>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function getStorageTestStepState({
index,
activeIndex,
failedIndex,
health,
}: {
index: number
activeIndex: number
failedIndex: number
health: StorageHealth
}): StorageTestStepState {
if (failedIndex === index && health.status !== 'testing') return 'failed'
if (activeIndex === index && health.status === 'testing') return 'active'
if (health.status === 'success') return 'done'
if (failedIndex >= 0) return index < failedIndex ? 'done' : 'pending'
if (activeIndex > index) return 'done'
return 'pending'
}
function storageTestStepClassName(state: StorageTestStepState) {
switch (state) {
case 'done':
return 'bg-green-500/5 text-foreground'
case 'failed':
return 'bg-destructive/5 text-destructive'
case 'active':
return 'bg-muted/50 text-foreground'
case 'pending':
return 'text-muted-foreground'
}
}
function storageTestStepStatusLabel(t: (key: string) => string, state: StorageTestStepState) {
switch (state) {
case 'done':
return t('admin.storages.testStepDone')
case 'failed':
return t('admin.storages.testStepFailed')
case 'active':
return t('admin.storages.testStepRunning')
case 'pending':
return t('admin.storages.testStepPending')
}
}
function StorageTestStepIcon({ state }: { state: StorageTestStepState }) {
if (state === 'active') return <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
if (state === 'failed') return <AlertTriangle className="size-4 shrink-0 text-destructive" />
if (state === 'done') return <CheckCircle2 className="size-4 shrink-0 text-green-700 dark:text-green-400" />
return <span className="size-4 shrink-0 rounded-full border" />
}
function StorageTestResult({ health }: { health: StorageHealth }) {
const { t } = useTranslation()
if (health.status === 'idle') return null
if (health.status === 'testing') {
return (
<div
data-testid="storage-test-result"
className="flex items-start gap-2 rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground"
>
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />
<span>{t('admin.storages.healthTesting')}</span>
</div>
)
}
if (health.status === 'success') {
return (
<div
data-testid="storage-test-result"
className="flex items-start gap-2 rounded-md border border-green-500/25 bg-green-500/5 p-3 text-sm text-green-700 dark:text-green-400"
>
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
<span>{health.message}</span>
</div>
)
}
return (
<div
data-testid="storage-test-result"
className="flex items-start gap-2 rounded-md border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span className="leading-5">{health.message}</span>
</div>
)
}
function StorageEgressBillingDrawer({
storage,
form,
@@ -364,25 +597,28 @@ function StorageEgressBillingDrawer({
onConfirm,
}: {
storage: Storage | null
form: EgressBillingForm
form: BillingForm
open: boolean
pending: boolean
hasTrafficBilling: boolean
onFormChange: (form: EgressBillingForm) => void
onFormChange: (form: BillingForm) => void
onOpenChange: (open: boolean) => void
onConfirm: () => void
}) {
const { t } = useTranslation()
const valid = Number(form.unitValue) >= 1 && Number(form.credits) >= 1
const canSave = hasTrafficBilling && valid
const validCapacity = Number(form.capacityValue) >= 0
const validEgress = Number(form.unitValue) >= 1 && Number(form.credits) >= 1
const canSave = validCapacity && (!hasTrafficBilling || !form.enabled || validEgress)
const billingFieldsDisabled = !hasTrafficBilling || !form.enabled
return (
<AdminFormDrawer
open={open}
onOpenChange={onOpenChange}
title={t('admin.storages.egressBillingTitle')}
description={t('admin.storages.egressBillingDescription', { title: storage?.title ?? '' })}
bodyClassName="grid gap-4"
onOpenAutoFocus={(event) => event.preventDefault()}
title={t('admin.storages.billingTitle')}
description={t('admin.storages.billingDescription', { bucket: storage?.bucket ?? '' })}
bodyClassName="space-y-3"
formProps={{
onSubmit: (event) => {
event.preventDefault()
@@ -390,148 +626,198 @@ function StorageEgressBillingDrawer({
},
}}
footer={
hasTrafficBilling ? (
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={pending || !canSave}>
{pending ? t('common.loading') : t('common.save')}
</Button>
</>
) : (
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{t('common.cancel')}
</Button>
)
<Button type="submit" disabled={pending || !canSave}>
{pending ? t('common.loading') : t('common.save')}
</Button>
</>
}
>
<div className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<Label htmlFor="egressCreditBillingEnabled">{t('admin.storages.egressBilling')}</Label>
<p className="text-xs text-muted-foreground">{t('admin.storages.egressBillingHint')}</p>
<section className="space-y-1.5">
<div className="min-w-0">
<AdminFormLabel htmlFor="storage-capacity-value" className="font-medium" required>
{t('admin.storages.fieldCapacity')}
</AdminFormLabel>
<p id="storage-capacity-description" className="text-xs leading-5 text-muted-foreground">
{t('admin.storages.capacityHint')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<DataAmountInput
id="storage-capacity-value"
describedBy="storage-capacity-description"
min={0}
placeholder={t('admin.storages.capacityPlaceholder')}
value={form.capacityValue}
unit={form.capacityUnit}
required
onValueChange={(capacityValue) => onFormChange({ ...form, capacityValue })}
onUnitChange={(capacityUnit) => onFormChange({ ...form, capacityUnit })}
/>
</div>
</section>
<section className="space-y-2 border-t pt-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<AdminFormLabel
htmlFor="egressCreditBillingEnabled"
className="font-medium"
help={t('admin.storages.egressBillingHint')}
>
<span>{t('admin.storages.egressBilling')}</span>
<ProBadge className="px-1.5 py-0 text-[10px] leading-4" />
</AdminFormLabel>
{!hasTrafficBilling && (
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{t('admin.storages.egressBillingBusinessOnly')}
</p>
)}
</div>
<Switch
id="egressCreditBillingEnabled"
className="mt-0.5"
disabled={!hasTrafficBilling}
checked={form.enabled}
onCheckedChange={(enabled) => onFormChange({ ...form, enabled: hasTrafficBilling && enabled })}
/>
</div>
{!hasTrafficBilling && (
<p className="mt-2 text-xs text-muted-foreground">{t('admin.storages.egressBillingBusinessOnly')}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<AdminFormField id="storage-egress-credit-unit-value" label={t('admin.storages.egressBillingUnit')}>
{(controlProps) => (
<div className="space-y-2.5">
<div className="space-y-1">
<AdminFormLabel htmlFor="storage-egress-credit-unit-value" required={hasTrafficBilling && form.enabled}>
{t('admin.storages.egressBillingUnit')}
</AdminFormLabel>
<div className="flex items-center gap-2">
<Input
{...controlProps}
type="number"
<DataAmountInput
id="storage-egress-credit-unit-value"
min={1}
step={1}
placeholder={t('admin.storages.egressBillingUnitPlaceholder')}
value={form.unitValue}
disabled={!hasTrafficBilling}
onChange={(event) => onFormChange({ ...form, unitValue: event.target.value })}
unit={form.unit}
disabled={billingFieldsDisabled}
required={hasTrafficBilling && form.enabled}
onValueChange={(unitValue) => onFormChange({ ...form, unitValue })}
onUnitChange={(unit) => onFormChange({ ...form, unit })}
/>
<Select value={form.unit} onValueChange={(unit) => onFormChange({ ...form, unit: unit as CreditUnit })}>
<SelectTrigger className="w-24" disabled={!hasTrafficBilling}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
<SelectItem value="TB">TB</SelectItem>
</SelectContent>
</Select>
</div>
)}
</AdminFormField>
<AdminFormField id="storage-egress-credit-per-unit" label={t('admin.storages.egressBillingCredits')}>
<Input
type="number"
min={1}
step={1}
value={form.credits}
disabled={!hasTrafficBilling}
onChange={(event) => onFormChange({ ...form, credits: event.target.value })}
/>
</AdminFormField>
</div>
</div>
<div className="space-y-1">
<AdminFormLabel htmlFor="storage-egress-credit-per-unit" required={hasTrafficBilling && form.enabled}>
{t('admin.storages.egressBillingCredits')}
</AdminFormLabel>
<Input
id="storage-egress-credit-per-unit"
type="number"
min={1}
step={1}
value={form.credits}
placeholder={t('admin.storages.egressBillingCreditsPlaceholder')}
disabled={billingFieldsDisabled}
aria-required={hasTrafficBilling && form.enabled ? true : undefined}
onChange={(event) => onFormChange({ ...form, credits: event.target.value })}
className="w-48"
/>
</div>
</div>
</section>
</AdminFormDrawer>
)
}
function emptyEgressBillingForm(): EgressBillingForm {
return { enabled: false, unitValue: '100', unit: 'MB', credits: '1' }
function emptyBillingForm(): BillingForm {
return {
capacityValue: '0',
capacityUnit: 'GB',
enabled: false,
unitValue: '100',
unit: 'MB',
credits: '1',
}
}
function egressBillingFormFromStorage(storage: Storage): EgressBillingForm {
const unit = bytesToCreditUnit(storage.egressCreditUnitBytes)
function billingFormFromStorage(storage: Storage): BillingForm {
const capacityUnit = storage.capacity > 0 ? bytesToUnit(storage.capacity) : 'GB'
const unit = bytesToUnit(storage.egressCreditUnitBytes)
return {
capacityValue: String(storage.capacity > 0 ? storage.capacity / DATA_UNITS[capacityUnit] : 0),
capacityUnit,
enabled: storage.egressCreditBillingEnabled,
unitValue: String(Math.max(1, storage.egressCreditUnitBytes / CREDIT_UNITS[unit])),
unitValue: String(Math.max(1, storage.egressCreditUnitBytes / DATA_UNITS[unit])),
unit,
credits: String(storage.egressCreditPerUnit),
}
}
function egressBillingPayload(form: EgressBillingForm) {
function capacityPayload(form: BillingForm) {
return Math.max(0, Math.floor(Number(form.capacityValue))) * DATA_UNITS[form.capacityUnit]
}
function egressBillingPayload(form: BillingForm) {
return {
enabled: form.enabled,
unitBytes: Math.max(1, Math.floor(Number(form.unitValue))) * CREDIT_UNITS[form.unit],
unitBytes: Math.max(1, Math.floor(Number(form.unitValue))) * DATA_UNITS[form.unit],
creditsPerUnit: Math.max(1, Math.floor(Number(form.credits))),
}
}
function bytesToCreditUnit(bytes: number): CreditUnit {
if (bytes >= CREDIT_UNITS.TB && bytes % CREDIT_UNITS.TB === 0) return 'TB'
if (bytes >= CREDIT_UNITS.GB && bytes % CREDIT_UNITS.GB === 0) return 'GB'
return 'MB'
}
function StorageHealthView({ health }: { health: StorageHealth }) {
const { t } = useTranslation()
if (health.status === 'idle') {
return <span className="text-xs text-muted-foreground">{t('admin.storages.healthUntested')}</span>
}
if (health.status === 'testing') {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t('admin.storages.healthTesting')}
</span>
)
}
if (health.status === 'success') {
return (
<span className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3" />
{health.message}
</span>
)
}
function DataAmountInput({
id,
describedBy,
value,
unit,
min,
placeholder,
disabled,
required,
onValueChange,
onUnitChange,
}: {
id: string
describedBy?: string
value: string
unit: DataUnit
min: number
placeholder?: string
disabled?: boolean
required?: boolean
onValueChange: (value: string) => void
onUnitChange: (unit: DataUnit) => void
}) {
return (
<div className="space-y-2">
<div className="flex items-start gap-1 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>{health.message}</span>
</div>
{health.status === 'cors' && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('admin.storages.testCorsCaveat')}</p>
<pre className="max-w-sm overflow-x-auto rounded border bg-muted p-2 text-xs text-muted-foreground">
{health.corsJson}
</pre>
</div>
)}
<div className="flex h-9 w-48 items-center overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-disabled:pointer-events-none has-disabled:opacity-50 dark:bg-input/30">
<Input
id={id}
aria-describedby={describedBy}
type="number"
min={min}
step={1}
value={value}
placeholder={placeholder}
disabled={disabled}
aria-required={required ? true : undefined}
onChange={(event) => onValueChange(event.target.value)}
className="h-8 flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0"
/>
<Select value={unit} disabled={disabled} onValueChange={(nextUnit) => onUnitChange(nextUnit as DataUnit)}>
<SelectTrigger className="h-8 w-20 rounded-none border-0 border-l bg-transparent px-2 shadow-none focus-visible:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MB">MB</SelectItem>
<SelectItem value="GB">GB</SelectItem>
<SelectItem value="TB">TB</SelectItem>
</SelectContent>
</Select>
</div>
)
}
function bytesToUnit(bytes: number): DataUnit {
if (bytes >= DATA_UNITS.TB && bytes % DATA_UNITS.TB === 0) return 'TB'
if (bytes >= DATA_UNITS.GB && bytes % DATA_UNITS.GB === 0) return 'GB'
return 'MB'
}