mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
feat(console,core): add attribute mapping (#6916)
This commit is contained in:
parent
b0fb35f97e
commit
73694b9962
10 changed files with 447 additions and 64 deletions
|
@ -5,6 +5,7 @@ export enum ApplicationDetailsTabs {
|
|||
Branding = 'branding',
|
||||
Permissions = 'permissions',
|
||||
Organizations = 'organizations',
|
||||
AttributeMapping = 'attribute-mapping',
|
||||
}
|
||||
|
||||
export enum ApiResourceDetailsTabs {
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
|
||||
> tr {
|
||||
padding-bottom: _.unit(3);
|
||||
}
|
||||
|
||||
* > th {
|
||||
font: var(--font-label-2);
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: _.unit(4);
|
||||
padding-bottom: _.unit(3);
|
||||
align-items: center;
|
||||
|
||||
> td,
|
||||
th {
|
||||
&:not(:last-child) {
|
||||
width: calc((100% - _.unit(4) - _.unit(10)) / 2);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
width: _.unit(10);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
width: 100%;
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
import { type UserClaim, completeUserClaims } from '@logto/core-kit';
|
||||
import { type SamlApplicationResponse, samlAttributeMappingKeys } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CircleMinus from '@/assets/icons/circle-minus.svg?react';
|
||||
import CirclePlus from '@/assets/icons/circle-plus.svg?react';
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import Button from '@/ds-components/Button';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import Select from '@/ds-components/Select';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
||||
import styles from './AttributeMapping.module.scss';
|
||||
import { camelCaseToSentenceCase } from './utils';
|
||||
|
||||
const defaultFormValue: Array<[UserClaim | 'id' | '', string]> = [['id', '']];
|
||||
|
||||
type Props = {
|
||||
readonly data: SamlApplicationResponse;
|
||||
readonly mutateApplication: (data?: SamlApplicationResponse) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for the attribute mapping form data.
|
||||
* Array of tuples containing key (UserClaim or 'id' or empty string) and value pairs
|
||||
*/
|
||||
type FormData = {
|
||||
attributeMapping: Array<[key: UserClaim | 'id' | '', value: string]>;
|
||||
};
|
||||
|
||||
const keyPrefix = 'attributeMapping';
|
||||
|
||||
const getOrderedAttributeMapping = (
|
||||
attributeMapping: SamlApplicationResponse['attributeMapping'] & Partial<Record<'', string>>
|
||||
) => {
|
||||
return (
|
||||
samlAttributeMappingKeys
|
||||
.filter((key) => key in attributeMapping)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
.map((key) => [key, attributeMapping[key]] as [UserClaim | 'id', string])
|
||||
);
|
||||
};
|
||||
|
||||
function AttributeMapping({ data, mutateApplication }: Props) {
|
||||
const { id, attributeMapping } = data;
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { getDocumentationUrl } = useDocumentationUrl();
|
||||
const api = useApi();
|
||||
|
||||
const {
|
||||
watch,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty },
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
defaultValues: {
|
||||
attributeMapping:
|
||||
Object.keys(attributeMapping).length > 0
|
||||
? getOrderedAttributeMapping(attributeMapping)
|
||||
: defaultFormValue,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = watch('attributeMapping');
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async (formData) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedData = await api
|
||||
.patch(`api/saml-applications/${id}`, {
|
||||
json: {
|
||||
attributeMapping: Object.fromEntries(
|
||||
formData.attributeMapping.filter(([key, value]) => Boolean(key) && Boolean(value))
|
||||
),
|
||||
},
|
||||
})
|
||||
.json<SamlApplicationResponse>();
|
||||
|
||||
mutateApplication(updatedData);
|
||||
toast.success(t('general.saved'));
|
||||
reset({
|
||||
attributeMapping:
|
||||
Object.keys(updatedData.attributeMapping).length > 0
|
||||
? getOrderedAttributeMapping(updatedData.attributeMapping)
|
||||
: defaultFormValue,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const existingKeys = useMemo(() => formValues.map(([key]) => key).filter(Boolean), [formValues]);
|
||||
|
||||
const availableKeys = useMemo(
|
||||
() => completeUserClaims.filter((claim) => !existingKeys.includes(claim)),
|
||||
[existingKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailsForm
|
||||
isSubmitting={isSubmitting}
|
||||
isDirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
onDiscard={reset}
|
||||
>
|
||||
<FormCard
|
||||
title="application_details.saml_app_attribute_mapping.title"
|
||||
description="application_details.saml_app_attribute_mapping.description"
|
||||
learnMoreLink={{
|
||||
// TODO: update this link once docs is ready
|
||||
href: getDocumentationUrl('/connectors/enterprise-connectors'),
|
||||
targetBlank: 'noopener',
|
||||
}}
|
||||
>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.header}>
|
||||
<tr className={styles.row}>
|
||||
<th>
|
||||
<DynamicT forKey="application_details.saml_app_attribute_mapping.col_logto_claims" />
|
||||
</th>
|
||||
<th>
|
||||
<DynamicT forKey="application_details.saml_app_attribute_mapping.col_sp_claims" />
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={styles.body}>
|
||||
{formValues.map(([key, _], index) => {
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<tr key={index} className={styles.row}>
|
||||
<td>
|
||||
{key === 'id' ? (
|
||||
<CopyToClipboard displayType="block" variant="border" value={key} />
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`${keyPrefix}.${index}.0` as const}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
isSearchEnabled
|
||||
value={value}
|
||||
options={[
|
||||
...availableKeys.map((claim) => ({
|
||||
title: camelCaseToSentenceCase(claim),
|
||||
value: claim,
|
||||
})),
|
||||
// If this is not specified, the component will fail to render the current value. The current value has been excluded in `availableKeys`.
|
||||
{ value, title: camelCaseToSentenceCase(value) },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<TextInput {...register(`${keyPrefix}.${index}.1`)} />
|
||||
</td>
|
||||
<td>
|
||||
{key !== 'id' && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
const currentValues = [
|
||||
...formValues.slice(0, index),
|
||||
...formValues.slice(index + 1),
|
||||
];
|
||||
setValue('attributeMapping', currentValues, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CircleMinus />
|
||||
</IconButton>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
disabled={availableKeys.length === 0}
|
||||
icon={<CirclePlus />}
|
||||
title="application_details.saml_app_attribute_mapping.add_button"
|
||||
onClick={() => {
|
||||
const currentValues = [...formValues];
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
currentValues.push(['', '']);
|
||||
setValue('attributeMapping', currentValues, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMapping;
|
|
@ -25,6 +25,7 @@ import { applicationTypeI18nKey } from '@/types/applications';
|
|||
import Branding from '../components/Branding';
|
||||
import Permissions from '../components/Permissions';
|
||||
|
||||
import AttributeMapping from './AttributeMapping';
|
||||
import Settings from './Settings';
|
||||
import styles from './index.module.scss';
|
||||
|
||||
|
@ -131,10 +132,12 @@ function SamlApplicationDetailsContent({ data }: Props) {
|
|||
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Settings}`}>
|
||||
{t('application_details.settings')}
|
||||
</TabNavItem>
|
||||
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.AttributeMapping}`}>
|
||||
{t('application_details.saml_app_attribute_mapping.name')}
|
||||
</TabNavItem>
|
||||
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
|
||||
{t('application_details.permissions.name')}
|
||||
</TabNavItem>
|
||||
{/** TODO: Attribute mapping tab */}
|
||||
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
|
||||
{t('application_details.branding.name')}
|
||||
</TabNavItem>
|
||||
|
@ -151,6 +154,14 @@ function SamlApplicationDetailsContent({ data }: Props) {
|
|||
/>
|
||||
)}
|
||||
</TabWrapper>
|
||||
<TabWrapper
|
||||
isActive={tab === ApplicationDetailsTabs.AttributeMapping}
|
||||
className={styles.tabContainer}
|
||||
>
|
||||
{samlApplicationData && (
|
||||
<AttributeMapping data={samlApplicationData} mutateApplication={mutateSamlApplication} />
|
||||
)}
|
||||
</TabWrapper>
|
||||
<TabWrapper
|
||||
isActive={tab === ApplicationDetailsTabs.Permissions}
|
||||
className={styles.tabContainer}
|
||||
|
|
|
@ -50,3 +50,15 @@ export const samlApplicationManagementApiPrefix = '/api/saml-applications';
|
|||
export const samlApplicationEndpointPrefix = '/saml';
|
||||
export const samlApplicationMetadataEndpointSuffix = 'metadata';
|
||||
export const samlApplicationSingleSignOnEndpointSuffix = 'authn';
|
||||
|
||||
export const camelCaseToSentenceCase = (input: string): string => {
|
||||
const words = input.split('_');
|
||||
|
||||
// If the first word is empty, return an empty string.
|
||||
if (!words[0]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const capitalizedFirstWord = words[0].charAt(0).toUpperCase() + words[0].slice(1);
|
||||
return [capitalizedFirstWord, ...words.slice(1)].join(' ');
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
BindingType,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||
import { removeUndefinedKeys, pick } from '@silverhand/essentials';
|
||||
import saml from 'samlify';
|
||||
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
|
@ -86,7 +86,9 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
|||
): Promise<SamlApplicationResponse> => {
|
||||
const { name, description, customData, ...config } = patchApplicationObject;
|
||||
const originalApplication = await findApplicationById(id);
|
||||
const applicationData = { name, description, customData };
|
||||
const applicationData = removeUndefinedKeys(
|
||||
pick(patchApplicationObject, 'name', 'description', 'customData')
|
||||
);
|
||||
|
||||
assertThat(
|
||||
originalApplication.type === ApplicationType.SAML,
|
||||
|
@ -98,7 +100,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
|||
|
||||
const [updatedApplication, upToDateSamlConfig] = await Promise.all([
|
||||
Object.keys(applicationData).length > 0
|
||||
? updateApplicationById(id, removeUndefinedKeys(applicationData))
|
||||
? updateApplicationById(id, applicationData)
|
||||
: originalApplication,
|
||||
Object.keys(config).length > 0
|
||||
? updateSamlApplicationConfig({
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { ApplicationType, BindingType, NameIdFormat } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import { createApplication, deleteApplication, updateApplication } from '#src/api/application.js';
|
||||
import {
|
||||
|
@ -75,46 +76,107 @@ describe('SAML application', () => {
|
|||
await deleteSamlApplication(createdSamlApplication.id);
|
||||
});
|
||||
|
||||
it('should be able to update SAML application and get the updated one', async () => {
|
||||
const createdSamlApplication = await createSamlApplication({
|
||||
it.each([
|
||||
{
|
||||
name: 'Update with ACS URL only',
|
||||
config: {
|
||||
acsUrl: {
|
||||
binding: BindingType.Post,
|
||||
url: 'https://example.logto.io/sso/saml',
|
||||
},
|
||||
entityId: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Update with Entity ID only',
|
||||
config: {
|
||||
acsUrl: null,
|
||||
entityId: 'https://example.logto.io/new-entity',
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
acsUrl: {
|
||||
binding: BindingType.Post,
|
||||
url: 'https://example.logto.io/sso/saml2',
|
||||
},
|
||||
entityId: 'https://example.logto.io/entity2',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Update with null values and attribute mapping',
|
||||
config: {
|
||||
acsUrl: null,
|
||||
entityId: null,
|
||||
attributeMapping: {
|
||||
id: 'sub',
|
||||
preferred_username: 'username',
|
||||
email: 'email_address',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Update with minimal attribute mapping',
|
||||
config: {
|
||||
attributeMapping: {
|
||||
id: 'sub',
|
||||
},
|
||||
},
|
||||
},
|
||||
])('should update SAML application - %#', async ({ name, config }) => {
|
||||
const formattedName = name ? `updated-${name}` : undefined;
|
||||
const initConfig = {
|
||||
name: 'test',
|
||||
description: 'test',
|
||||
entityId: 'http://example.logto.io/foo',
|
||||
});
|
||||
expect(createdSamlApplication.entityId).toEqual('http://example.logto.io/foo');
|
||||
};
|
||||
// Create initial SAML application
|
||||
const createdSamlApplication = await createSamlApplication(initConfig);
|
||||
|
||||
expect(createdSamlApplication.entityId).toEqual(initConfig.entityId);
|
||||
// Default values
|
||||
expect(createdSamlApplication.acsUrl).toEqual(null);
|
||||
expect(createdSamlApplication.attributeMapping).toEqual({});
|
||||
expect(createdSamlApplication.nameIdFormat).toEqual(NameIdFormat.Persistent);
|
||||
expect(createdSamlApplication.encryption).toBe(null);
|
||||
|
||||
const newConfig = {
|
||||
acsUrl: {
|
||||
binding: BindingType.Post,
|
||||
url: 'https://example.logto.io/sso/saml',
|
||||
},
|
||||
entityId: null,
|
||||
nameIdFormat: NameIdFormat.EmailAddress,
|
||||
encryption: {
|
||||
encryptAssertion: false,
|
||||
},
|
||||
};
|
||||
// Update application with new config
|
||||
const updatedSamlApplication = await updateSamlApplication(createdSamlApplication.id, {
|
||||
name: 'updated',
|
||||
...newConfig,
|
||||
...conditional(name && { name: formattedName }),
|
||||
...config,
|
||||
});
|
||||
expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl);
|
||||
expect(updatedSamlApplication.entityId).toEqual(newConfig.entityId);
|
||||
expect(updatedSamlApplication.attributeMapping).toEqual({});
|
||||
expect(updatedSamlApplication.nameIdFormat).toEqual(newConfig.nameIdFormat);
|
||||
expect(updatedSamlApplication.encryption).toEqual(newConfig.encryption);
|
||||
|
||||
// Verify update was successful
|
||||
if (config.acsUrl) {
|
||||
expect(updatedSamlApplication.acsUrl).toEqual(config.acsUrl);
|
||||
} else {
|
||||
expect(updatedSamlApplication.acsUrl).toBe(null);
|
||||
}
|
||||
|
||||
if (config.entityId) {
|
||||
expect(updatedSamlApplication.entityId).toEqual(config.entityId);
|
||||
} else {
|
||||
expect(updatedSamlApplication.entityId).toBe(
|
||||
config.entityId === null ? null : initConfig.entityId
|
||||
);
|
||||
}
|
||||
|
||||
if (config.attributeMapping) {
|
||||
expect(updatedSamlApplication.attributeMapping).toEqual(config.attributeMapping);
|
||||
} else {
|
||||
expect(updatedSamlApplication.attributeMapping).toEqual({});
|
||||
}
|
||||
|
||||
if (name) {
|
||||
expect(updatedSamlApplication.name).toEqual(formattedName);
|
||||
} else {
|
||||
expect(updatedSamlApplication.name).toEqual(initConfig.name);
|
||||
}
|
||||
|
||||
// Verify get returns same data
|
||||
const upToDateSamlApplication = await getSamlApplication(createdSamlApplication.id);
|
||||
|
||||
expect(updatedSamlApplication).toEqual(upToDateSamlApplication);
|
||||
expect(updatedSamlApplication.name).toEqual('updated');
|
||||
expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl);
|
||||
|
||||
await deleteSamlApplication(updatedSamlApplication.id);
|
||||
await deleteSamlApplication(createdSamlApplication.id);
|
||||
});
|
||||
|
||||
it('can not delete/update/get non-SAML applications with `DEL /saml-applications/:id` API', async () => {
|
||||
|
|
|
@ -219,6 +219,14 @@ const application_details = {
|
|||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
},
|
||||
saml_app_attribute_mapping: {
|
||||
name: 'Attribute mappings',
|
||||
title: 'Base attribute mappings',
|
||||
description: 'Add attribute mappings to sync user profile from Logto to your application.',
|
||||
col_logto_claims: 'Value of Logto',
|
||||
col_sp_claims: 'Value name of your application',
|
||||
add_button: 'Add another',
|
||||
},
|
||||
};
|
||||
|
||||
export default Object.freeze(application_details);
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import { type ToZodObject } from '@logto/connector-kit';
|
||||
import { completeUserClaims, type UserClaim } from '@logto/core-kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type SamlAttributeMapping = Record<string, string>;
|
||||
export type SamlAttributeMapping = Partial<Record<UserClaim | 'id', string>>;
|
||||
|
||||
export const samlAttributeMappingGuard = z.record(
|
||||
z.string()
|
||||
) satisfies z.ZodType<SamlAttributeMapping>;
|
||||
export const samlAttributeMappingKeys = Object.freeze(['id', ...completeUserClaims] satisfies Array<
|
||||
keyof SamlAttributeMapping
|
||||
>);
|
||||
|
||||
export const samlAttributeMappingGuard = z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
samlAttributeMappingKeys.map((claim): [UserClaim | 'id', z.ZodString] => [claim, z.string()])
|
||||
)
|
||||
)
|
||||
.partial() satisfies z.ZodType<SamlAttributeMapping>;
|
||||
|
||||
export enum BindingType {
|
||||
Post = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/** Scopes that reserved by Logto, which will be added to the auth request automatically. */
|
||||
export enum ReservedScope {
|
||||
OpenId = 'openid',
|
||||
|
@ -14,37 +16,52 @@ export enum ReservedResource {
|
|||
Organization = 'urn:logto:resource:organizations',
|
||||
}
|
||||
|
||||
export type UserClaim =
|
||||
/**
|
||||
* A comprehensive list of all available user claims that can be used in SAML applications.
|
||||
* These claims can be mapped to SAML attributes in the application configuration.
|
||||
*
|
||||
* Note: This array must include ALL possible values from UserClaim type.
|
||||
* TypeScript will throw a compile-time error if any value is missing.
|
||||
*/
|
||||
export const completeUserClaims = [
|
||||
// OIDC standard claims
|
||||
| 'name'
|
||||
| 'given_name'
|
||||
| 'family_name'
|
||||
| 'middle_name'
|
||||
| 'nickname'
|
||||
| 'preferred_username'
|
||||
| 'profile'
|
||||
| 'picture'
|
||||
| 'website'
|
||||
| 'email'
|
||||
| 'email_verified'
|
||||
| 'gender'
|
||||
| 'birthdate'
|
||||
| 'zoneinfo'
|
||||
| 'locale'
|
||||
| 'phone_number'
|
||||
| 'phone_number_verified'
|
||||
| 'address'
|
||||
| 'updated_at'
|
||||
'name',
|
||||
'given_name',
|
||||
'family_name',
|
||||
'middle_name',
|
||||
'nickname',
|
||||
'preferred_username',
|
||||
'profile',
|
||||
'picture',
|
||||
'website',
|
||||
'email',
|
||||
'email_verified',
|
||||
'gender',
|
||||
'birthdate',
|
||||
'zoneinfo',
|
||||
'locale',
|
||||
'phone_number',
|
||||
'phone_number_verified',
|
||||
'address',
|
||||
'updated_at',
|
||||
// Custom claims
|
||||
| 'username'
|
||||
| 'roles'
|
||||
| 'organizations'
|
||||
| 'organization_data'
|
||||
| 'organization_roles'
|
||||
| 'custom_data'
|
||||
| 'identities'
|
||||
| 'sso_identities'
|
||||
| 'created_at';
|
||||
'username',
|
||||
'roles',
|
||||
'organizations',
|
||||
'organization_data',
|
||||
'organization_roles',
|
||||
'custom_data',
|
||||
'identities',
|
||||
'sso_identities',
|
||||
'created_at',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Zod guard for UserClaim type, using completeUserClaims as the single source of truth
|
||||
*/
|
||||
export const userClaimGuard = z.enum(completeUserClaims);
|
||||
|
||||
export type UserClaim = z.infer<typeof userClaimGuard>;
|
||||
|
||||
/**
|
||||
* Scopes for ID Token and Userinfo Endpoint.
|
||||
|
|
Loading…
Add table
Reference in a new issue