Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drizzle/0001_green_warpath.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "applications" ADD COLUMN "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL;
9 changes: 8 additions & 1 deletion drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1776085749779,
"tag": "0000_initial_schema",
"breakpoints": false
},
{
"idx": 1,
"version": "7",
"when": 1777905152125,
"tag": "0001_green_warpath",
"breakpoints": true
}
]
}
}
9 changes: 9 additions & 0 deletions frontend/src/api/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
}

export async function createApplication(body: CreateApplicationBody): Promise<ApplicationCreateResponse> {
Expand All @@ -49,6 +57,7 @@ export async function createApplication(body: CreateApplicationBody): Promise<Ap
url: body.url ?? null,
icon: body.icon ?? null,
enabledSocialProviders: body.enabledSocialProviders ?? null,
metadata: body.metadata ?? {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
Expand Down
114 changes: 114 additions & 0 deletions frontend/src/components/applications/ApplicationFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,16 @@ const form = ref({
allowedScopes: ['openid', 'profile', 'email', 'roles', 'permissions', 'features'] as string[],
redirectUris: [''] as string[],
enabledSocialProviders: null as string[] | null,
// Metadata is edited as an ordered list of key/value pairs in the UI;
// we serialize back to a plain Record<string, string> 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, '');
}
Expand Down Expand Up @@ -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 = '';
Expand All @@ -119,6 +131,7 @@ watch(
allowedScopes: ['openid', 'profile', 'email', 'roles', 'permissions', 'features'],
redirectUris: [''],
enabledSocialProviders: null,
metadata: [],
};
}
},
Expand Down Expand Up @@ -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<Set<string>>(() => {
const seen = new Map<string, number>();
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<string, string>.
const metadata: Record<string, string> = {};
for (const { key, value } of form.value.metadata) {
if (!key) continue;
metadata[key] = value;
}
loading.value = true;
try {
const body = {
Expand All @@ -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);
Expand Down Expand Up @@ -352,6 +403,69 @@ async function submit() {
</div>
</div>
</div>

<!--
Metadata: per-application key/value pairs that AuthService injects
into JWTs under the `client_attrs` field. Used by resource servers
such as EMQX (ACL via `${client_attrs.NAME}` placeholders).
Keys must be valid identifiers (server-enforced), values are strings.
-->
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-surface-500 uppercase tracking-wider">
{{ t('applications.metadata') }}
</p>
<button
type="button"
@click="addMetadataEntry"
class="text-xs text-primary-400 hover:text-primary-300 transition-colors font-medium"
>
+ {{ t('applications.addMetadataEntry') }}
</button>
</div>
<p class="text-xs text-surface-500 mb-2">
{{ t('applications.metadataDescription') }}
</p>
<div v-if="form.metadata.length === 0" class="text-xs text-surface-600 italic py-1">
{{ t('applications.metadataEmpty') }}
</div>
<div v-else class="space-y-2">
<div
v-for="(entry, i) in form.metadata"
:key="i"
class="flex gap-2"
>
<input
v-model="entry.key"
:placeholder="t('applications.metadataKeyPlaceholder')"
:class="[
'w-1/3 px-3 py-2 text-sm bg-surface-800/80 border rounded-md text-surface-100 placeholder:text-surface-600 focus:outline-none focus:ring-2 focus:ring-primary-500/40 focus:border-primary-500/60 transition-all font-mono',
isMetadataKeyValid(entry.key) && !metadataDuplicateKeys.has(entry.key)
? 'border-surface-700/60'
: 'border-red-500/60',
]"
/>
<input
v-model="entry.value"
:placeholder="t('applications.metadataValuePlaceholder')"
class="flex-1 px-3 py-2 text-sm bg-surface-800/80 border border-surface-700/60 rounded-md text-surface-100 placeholder:text-surface-600 focus:outline-none focus:ring-2 focus:ring-primary-500/40 focus:border-primary-500/60 transition-all"
/>
<button
type="button"
@click="removeMetadataEntry(i)"
class="px-2 text-surface-600 hover:text-red-400 transition-colors text-lg leading-none"
>
</button>
</div>
</div>
<p
v-if="metadataDuplicateKeys.size > 0"
class="text-xs text-red-400 mt-2"
>
{{ t('applications.metadataDuplicateKey') }}
</p>
</div>
</form>
<template #footer>
<BaseButton variant="ghost" @click="emit('close')">{{ t('common.cancel') }}</BaseButton>
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ export default {
addRedirectUri: 'Add redirect URI',
inheritProviders: 'Inherit global config',
overrideProviders: 'Override providers',
metadata: 'Metadata (JWT client_attrs)',
metadataDescription:
'Per-application key/value pairs surfaced to resource servers via the JWT `client_attrs` field. Used for example by EMQX ACL placeholders such as ${client_attrs.agent_owner}.',
metadataEmpty: 'No metadata. Add an entry to attach custom claims to issued tokens.',
metadataKeyPlaceholder: 'key (identifier)',
metadataValuePlaceholder: 'value (string)',
addMetadataEntry: 'Add entry',
metadataDuplicateKey: 'Duplicate key \u2014 each metadata key must be unique.',
metadataInvalid: 'Metadata contains invalid keys. Keys must be valid identifiers and unique.',
filterActive: 'Filter by active',
filterType: 'Filter by type',
filterMfa: 'Filter by MFA',
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ export default {
addRedirectUri: 'Ajouter une URI',
inheritProviders: 'Hériter de la config globale',
overrideProviders: 'Remplacer les fournisseurs',
metadata: 'Métadonnées (JWT client_attrs)',
metadataDescription:
'Paires clé/valeur spécifiques à l’application, exposées aux services consommateurs via le champ JWT `client_attrs`. Utilisées par exemple par les ACL EMQX via ${client_attrs.agent_owner}.',
metadataEmpty: 'Aucune métadonnée. Ajoutez une entrée pour injecter des claims personnalisés dans les tokens.',
metadataKeyPlaceholder: 'clé (identifiant)',
metadataValuePlaceholder: 'valeur (chaîne)',
addMetadataEntry: 'Ajouter une entrée',
metadataDuplicateKey: 'Clé dupliquée — chaque clé de métadonnées doit être unique.',
metadataInvalid: 'Les métadonnées contiennent des clés invalides. Les clés doivent être des identifiants valides et uniques.',
filterActive: 'Filtrer par statut',
filterType: 'Filtrer par type',
filterMfa: 'Filtrer par MFA',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/mocks/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export const MOCK_APPLICATIONS: Application[] = [
url: 'https://dashboard.circle.internal',
icon: null,
enabledSocialProviders: null,
metadata: {},
createdAt: '2024-01-15T10:00:00.000Z',
updatedAt: '2024-11-01T12:00:00.000Z',
},
Expand All @@ -144,6 +145,7 @@ export const MOCK_APPLICATIONS: Application[] = [
url: 'https://app.acme.com',
icon: 'https://acme.com/favicon.ico',
enabledSocialProviders: ['google', 'github'],
metadata: {},
createdAt: '2024-03-22T14:30:00.000Z',
updatedAt: '2024-10-15T09:45:00.000Z',
},
Expand All @@ -162,6 +164,7 @@ export const MOCK_APPLICATIONS: Application[] = [
url: null,
icon: null,
enabledSocialProviders: [],
metadata: { client_kind: 'server' },
createdAt: '2024-06-01T08:00:00.000Z',
updatedAt: '2024-06-01T08:00:00.000Z',
},
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/mocks/mocks/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export const MOCK_APPLICATIONS: Application[] = [
url: 'https://dashboard.circle.internal',
icon: null,
enabledSocialProviders: null,
metadata: {},
createdAt: '2024-01-15T10:00:00.000Z',
updatedAt: '2024-11-01T12:00:00.000Z',
},
Expand All @@ -144,6 +145,7 @@ export const MOCK_APPLICATIONS: Application[] = [
url: 'https://app.acme.com',
icon: 'https://acme.com/favicon.ico',
enabledSocialProviders: ['google', 'github'],
metadata: {},
createdAt: '2024-03-22T14:30:00.000Z',
updatedAt: '2024-10-15T09:45:00.000Z',
},
Expand All @@ -162,6 +164,7 @@ export const MOCK_APPLICATIONS: Application[] = [
url: null,
icon: null,
enabledSocialProviders: [],
metadata: { client_kind: 'server' },
createdAt: '2024-06-01T08:00:00.000Z',
updatedAt: '2024-06-01T08:00:00.000Z',
},
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface Application {
url: string | null
icon: string | null
enabledSocialProviders: string[] | null
metadata: Record<string, string>
createdAt: string
updatedAt: string
}
Expand Down
Loading
Loading