diff --git a/drizzle/0001_green_warpath.sql b/drizzle/0001_green_warpath.sql new file mode 100644 index 0000000..d552e90 --- /dev/null +++ b/drizzle/0001_green_warpath.sql @@ -0,0 +1 @@ +ALTER TABLE "applications" ADD COLUMN "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 19799fd..d119863 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776085749779, "tag": "0000_initial_schema", "breakpoints": false + }, + { + "idx": 1, + "version": "7", + "when": 1777905152125, + "tag": "0001_green_warpath", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/frontend/src/api/applications.ts b/frontend/src/api/applications.ts index f4b24db..9b26c09 100644 --- a/frontend/src/api/applications.ts +++ b/frontend/src/api/applications.ts @@ -30,6 +30,14 @@ export interface CreateApplicationBody { url?: string icon?: string enabledSocialProviders?: string[] | null + /** + * Free-form per-application metadata. Stored as a flat string→string map + * and surfaced inside the JWT under the `client_attrs` field for resource + * servers (e.g. EMQX uses `client_attrs.NAME` in its ACL placeholders). + * Reserved JWT claim names (sub, iss, aud, exp, etc.) are rejected + * server-side; keys must be valid identifiers. + */ + metadata?: Record } export async function createApplication(body: CreateApplicationBody): Promise { @@ -49,6 +57,7 @@ export async function createApplication(body: CreateApplicationBody): Promise on submit. Keys must + // be valid identifiers (server enforces /^[a-zA-Z_][a-zA-Z0-9_]*$/) and + // both keys/values are strings (EMQX `client_attrs` contract). + metadata: [] as Array<{ key: string; value: string }>, }); +// Validate identifier-style keys client-side (server enforces too). +const METADATA_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + function slugify(name: string) { return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); } @@ -102,6 +110,10 @@ watch( enabledSocialProviders: a.enabledSocialProviders ? [...a.enabledSocialProviders] : null, + metadata: Object.entries(a.metadata ?? {}).map(([key, value]) => ({ + key, + value: String(value), + })), }; } else { lastAutoRedirectUri.value = ''; @@ -119,6 +131,7 @@ watch( allowedScopes: ['openid', 'profile', 'email', 'roles', 'permissions', 'features'], redirectUris: [''], enabledSocialProviders: null, + metadata: [], }; } }, @@ -179,9 +192,46 @@ function removeRedirectUri(i: number) { form.value.redirectUris.splice(i, 1); } +function addMetadataEntry() { + form.value.metadata.push({ key: '', value: '' }); +} +function removeMetadataEntry(i: number) { + form.value.metadata.splice(i, 1); +} +function isMetadataKeyValid(key: string): boolean { + return key === '' || METADATA_KEY_RE.test(key); +} +const metadataDuplicateKeys = computed>(() => { + const seen = new Map(); + for (const { key } of form.value.metadata) { + if (!key) continue; + seen.set(key, (seen.get(key) ?? 0) + 1); + } + return new Set([...seen.entries()].filter(([, n]) => n > 1).map(([k]) => k)); +}); +const metadataHasError = computed(() => { + if (metadataDuplicateKeys.value.size > 0) return true; + return form.value.metadata.some( + ({ key, value }) => + // Empty rows are silently dropped on submit, but a row with only one + // side filled is an error. + (key === '' && value !== '') || (key !== '' && !METADATA_KEY_RE.test(key)), + ); +}); + async function submit() { if (!form.value.name) return; if (!isEdit.value && !form.value.slug) return; + if (metadataHasError.value) { + toast.error(t('applications.metadataInvalid')); + return; + } + // Drop empty rows; collapse to a plain Record. + const metadata: Record = {}; + for (const { key, value } of form.value.metadata) { + if (!key) continue; + metadata[key] = value; + } loading.value = true; try { const body = { @@ -196,6 +246,7 @@ async function submit() { allowedScopes: form.value.allowedScopes, redirectUris: form.value.redirectUris.filter(Boolean), enabledSocialProviders: form.value.enabledSocialProviders, + metadata, }; if (isEdit.value && props.application) { const res = await updateApplication(props.application.id, body); @@ -352,6 +403,69 @@ async function submit() { + + +
+
+

+ {{ t('applications.metadata') }} +

+ +
+

+ {{ t('applications.metadataDescription') }} +

+
+ {{ t('applications.metadataEmpty') }} +
+
+
+ + + +
+
+

+ {{ t('applications.metadataDuplicateKey') }} +

+