mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
refactor(admin): standardize management forms
This commit is contained in:
@@ -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.0–v2.9)
|
||||
- [docs/roadmap/](docs/roadmap/) — per-version technical specs (v2.0.md–v2.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)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `storages` DROP COLUMN `title`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
`)
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()})`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,7 +45,6 @@ describe('signUpSchema', () => {
|
||||
|
||||
describe('createStorageSchema', () => {
|
||||
const valid = {
|
||||
title: 'My S3',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
accessKey: 'AK',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'), {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user