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
48 changes: 33 additions & 15 deletions apps/rpc/src/modules/user/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ import {
import type { UserRepository } from "./user-repository"
import { mergeUsers } from "./user-merging"
import type { GroupRepository } from "../group/group-repository"
import type { Auth0Connection } from "./user"
import {
Auth0UserProfileAppMetadataSchema,
Auth0UserProfileUserMetadataSchema,
type Auth0Connection,
type Auth0UserProfile,
} from "./user"

export interface UserService {
register(handle: DBHandle, subject: string): Promise<User>
Expand Down Expand Up @@ -170,6 +175,29 @@ export function getUserService(
): UserService {
const logger = getLogger("user-service")

function parseAuth0ProfileMetadata(auth0User: Auth0UserProfile) {
const appMetadataResult = Auth0UserProfileAppMetadataSchema.safeParse(auth0User.app_metadata ?? {})
if (!appMetadataResult.success) {
logger.error("Failed to parse Auth0 app metadata for User(ID=%s): %o", auth0User.user_id, appMetadataResult.error)
}

const userMetadataResult = Auth0UserProfileUserMetadataSchema.safeParse(auth0User.user_metadata ?? {})
if (!userMetadataResult.success) {
logger.error(
"Failed to parse Auth0 user metadata for User(ID=%s): %o",
auth0User.user_id,
userMetadataResult.error
)
}

return {
appMetadata: appMetadataResult.success ? appMetadataResult.data : {},
userMetadata: userMetadataResult.success ? userMetadataResult.data : {},
}
}

type Auth0ProfileMetadata = ReturnType<typeof parseAuth0ProfileMetadata>

async function findApplicableMembership(
studyProgrammes: NTNUGroup[],
studySpecializations: NTNUGroup[],
Expand Down Expand Up @@ -368,24 +396,14 @@ export function getUserService(

await userRepository.register(handle, userId)

const requestedSlug = UserWriteSchema.shape.username
.catch(crypto.randomUUID())
.parse(response.data.app_metadata?.username)

// Profile slugs are unique, so if somebody has already the requested slug as their profile slug, we change the
// requested slug to a new random UUID for now. The user can always update this later.
const match = await this.findByUsername(handle, requestedSlug)
const slug = match !== null ? crypto.randomUUID() : requestedSlug

const profile: UserWrite = {
username: slug,
username: crypto.randomUUID(),
name: response.data.name,
email: response.data.email,
imageUrl: response.data.picture,
biography: response.data.app_metadata?.biography || null,
phone: response.data.app_metadata?.phone || null,
// This field was called `allergies` in OnlineWeb 4, but today it's called `dietaryRestrictions`.
dietaryRestrictions: response.data.app_metadata?.allergies || null,
biography: null,
phone: null,
dietaryRestrictions: null,
gender: GenderSchema.enum.UNKNOWN,
workspaceUserId: null,
}
Expand Down
29 changes: 28 additions & 1 deletion apps/rpc/src/modules/user/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { GetUsers200ResponseOneOfInnerIdentitiesInner } from "auth0"
import type { GetUsers200ResponseOneOfInnerIdentitiesInner, ManagementClient } from "auth0"
import { z } from "zod"

export const Auth0ConnectionSchema = z.object({
Expand All @@ -8,3 +8,30 @@ export const Auth0ConnectionSchema = z.object({
})

export type Auth0Connection = z.infer<typeof Auth0ConnectionSchema>

export type Auth0UserProfile = Awaited<ReturnType<ManagementClient["users"]["get"]>>["data"]

export const Auth0UserProfileUserMetadataSchema = z
.object({
// The name a user entered when registering (since April 2026). Used for making sure we don't replace the user's
// name with the Feide name if an admin has manually updated it.
full_name: z.string(),
})
.partial()
.passthrough()

export type Auth0UserProfileUserMetadata = z.infer<typeof Auth0UserProfileUserMetadataSchema>

// Old users from OnlineWeb 4 (previous version of the website) may have some metadata used for the migration into
// Auth0. Only the fields defined here should still be in active use.
export const Auth0UserProfileAppMetadataSchema = z
.object({
// The user's full name as provided by Feide. Used for replacing user-entered names.
feide_full_name: z.string(),
// Immutable copy of user metadata field `full_name`. Semantically, user metadata is editable by the user.
initial_full_name: z.string(),
})
.partial()
.passthrough()

export type Auth0UserProfileAppMetadata = z.infer<typeof Auth0UserProfileAppMetadataSchema>
Loading