From 0dfba43aa6e44a48b99a7361866956eab1c06656 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 14 Jul 2026 18:09:43 +0200 Subject: [PATCH] groups und impersination --- listify-api/src/auth/auth.module.ts | 9 +- listify-api/src/auth/auth.service.spec.ts | 145 +++++++++++++++++- listify-api/src/auth/auth.service.ts | 113 +++++++++++++- listify-api/src/auth/auth.types.ts | 3 + listify-api/src/auth/jwt-auth.guard.spec.ts | 11 ++ listify-api/src/auth/oidc.service.ts | 59 ++++++- .../src/auth/user-impersonation.entity.ts | 61 ++++++++ .../src/auth/user-keycloak-group.entity.ts | 49 ++++++ listify-api/src/database/data-source.ts | 4 + .../1782300000000-CreateUserImpersonations.ts | 38 +++++ .../1782400000000-CreateUserKeycloakGroups.ts | 33 ++++ listify-api/test/app.e2e-spec.ts | 10 +- .../src/app/account/account.component.html | 20 +++ .../src/app/account/account.component.scss | 30 ++++ listify-client/src/app/auth/auth.models.ts | 1 + listify-client/src/app/auth/auth.service.ts | 14 +- 16 files changed, 583 insertions(+), 17 deletions(-) create mode 100644 listify-api/src/auth/user-impersonation.entity.ts create mode 100644 listify-api/src/auth/user-keycloak-group.entity.ts create mode 100644 listify-api/src/database/migrations/1782300000000-CreateUserImpersonations.ts create mode 100644 listify-api/src/database/migrations/1782400000000-CreateUserKeycloakGroups.ts diff --git a/listify-api/src/auth/auth.module.ts b/listify-api/src/auth/auth.module.ts index c519559..749ff97 100644 --- a/listify-api/src/auth/auth.module.ts +++ b/listify-api/src/auth/auth.module.ts @@ -8,13 +8,20 @@ import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { McpAuthGuard } from './mcp-auth.guard'; import { OidcService } from './oidc.service'; +import { UserKeycloakGroupEntity } from './user-keycloak-group.entity'; +import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; @Module({ imports: [ AuditModule, JwtModule.register({}), - TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]), + TypeOrmModule.forFeature([ + UserEntity, + RefreshTokenEntity, + UserKeycloakGroupEntity, + UserImpersonationEntity, + ]), ], controllers: [AuthController], providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard], diff --git a/listify-api/src/auth/auth.service.spec.ts b/listify-api/src/auth/auth.service.spec.ts index 2fc3d64..f6d17e1 100644 --- a/listify-api/src/auth/auth.service.spec.ts +++ b/listify-api/src/auth/auth.service.spec.ts @@ -6,6 +6,8 @@ import { AuthTokenResponse, JwtTokenPayload } from './auth.types'; import { AuthService } from './auth.service'; import { OidcProfile, OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; +import { UserKeycloakGroupEntity } from './user-keycloak-group.entity'; +import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; import { InMemoryRepository } from '../testing/in-memory-repository'; @@ -14,6 +16,7 @@ class FakeOidcService { subject: 'oidc-user-1', email: 'User@Example.com', name: 'Test User', + groups: [], }; createAuthorizationUrl = jest.fn( @@ -27,9 +30,17 @@ describe('AuthService', () => { let authService: AuthService; let jwtService: JwtService; let oidcService: FakeOidcService; + let usersRepository: InMemoryRepository; + let userKeycloakGroupsRepository: InMemoryRepository; + let userImpersonationsRepository: InMemoryRepository; beforeEach(async () => { oidcService = new FakeOidcService(); + usersRepository = new InMemoryRepository(); + userKeycloakGroupsRepository = + new InMemoryRepository(); + userImpersonationsRepository = + new InMemoryRepository(); module = await Test.createTestingModule({ imports: [EventEmitterModule.forRoot(), JwtModule.register({})], providers: [ @@ -40,12 +51,20 @@ describe('AuthService', () => { }, { provide: getRepositoryToken(UserEntity), - useValue: new InMemoryRepository(), + useValue: usersRepository, }, { provide: getRepositoryToken(RefreshTokenEntity), useValue: new InMemoryRepository(), }, + { + provide: getRepositoryToken(UserKeycloakGroupEntity), + useValue: userKeycloakGroupsRepository, + }, + { + provide: getRepositoryToken(UserImpersonationEntity), + useValue: userImpersonationsRepository, + }, ], }).compile(); await module.init(); @@ -81,6 +100,7 @@ describe('AuthService', () => { subject: 'oidc-user-1', email: 'renamed@example.com', name: 'Renamed User', + groups: [], }; const secondLogin = await authService.completeSsoLogin('code', 'state'); @@ -96,6 +116,7 @@ describe('AuthService', () => { subject: 'oidc-user-2', email: 'User@Example.com', name: 'Linked User', + groups: [], }; const secondLogin = await authService.completeSsoLogin('code', 'state'); @@ -129,6 +150,116 @@ describe('AuthService', () => { expect(refreshPayload.jti).toBeDefined(); }); + it('syncs Keycloak groups from the SSO profile', async () => { + oidcService.profile.groups = [ + '/teams/engineering', + '/teams/admins', + '/teams/engineering', + ]; + + const loginResponse = await authService.completeSsoLogin('code', 'state'); + + expect(loginResponse.user.groups).toEqual([ + '/teams/admins', + '/teams/engineering', + ]); + + oidcService.profile = { + subject: 'oidc-user-1', + email: 'user@example.com', + name: 'Test User', + groups: ['/teams/support'], + }; + + const secondLoginResponse = await authService.completeSsoLogin( + 'code', + 'state', + ); + const storedGroups = await userKeycloakGroupsRepository.find({ + where: { userId: secondLoginResponse.user.id }, + }); + + expect(secondLoginResponse.user.groups).toEqual(['/teams/support']); + expect(storedGroups.map((group) => group.groupPath)).toEqual([ + '/teams/support', + ]); + }); + + it('resolves active database impersonation for access tokens', async () => { + const loginResponse = await loginWithSso(); + const targetUser = await createUser('target-user-id', 'target@example.com'); + + await userImpersonationsRepository.save( + userImpersonationsRepository.create({ + id: 'impersonation-1', + impersonatorUserId: loginResponse.user.id, + targetUserId: targetUser.id, + enabled: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }), + ); + + const payload = await authService.verifyAccessToken( + loginResponse.accessToken, + ); + + expect(payload.sub).toBe(targetUser.id); + expect(payload.email).toBe(targetUser.email); + expect(payload.impersonatorSub).toBe(loginResponse.user.id); + expect(payload.impersonatorEmail).toBe(loginResponse.user.email); + }); + + it('returns the effective user when refreshing during impersonation', async () => { + const loginResponse = await loginWithSso(); + const targetUser = await createUser('target-user-id', 'target@example.com'); + + await userImpersonationsRepository.save( + userImpersonationsRepository.create({ + id: 'impersonation-1', + impersonatorUserId: loginResponse.user.id, + targetUserId: targetUser.id, + enabled: true, + }), + ); + + const refreshResponse = await authService.refresh({ + refreshToken: loginResponse.refreshToken, + }); + + expect(refreshResponse.user.id).toBe(targetUser.id); + expect(refreshResponse.user.email).toBe(targetUser.email); + }); + + it('ignores disabled and expired database impersonation records', async () => { + const loginResponse = await loginWithSso(); + const targetUser = await createUser('target-user-id', 'target@example.com'); + + await userImpersonationsRepository.save([ + userImpersonationsRepository.create({ + id: 'impersonation-disabled', + impersonatorUserId: loginResponse.user.id, + targetUserId: targetUser.id, + enabled: false, + }), + userImpersonationsRepository.create({ + id: 'impersonation-expired', + impersonatorUserId: loginResponse.user.id, + targetUserId: targetUser.id, + enabled: true, + expiresAt: new Date(Date.now() - 1_000), + }), + ]); + + const payload = await authService.verifyAccessToken( + loginResponse.accessToken, + ); + + expect(payload.sub).toBe(loginResponse.user.id); + expect(payload.email).toBe(loginResponse.user.email); + expect(payload.impersonatorSub).toBeUndefined(); + expect(payload.impersonatorEmail).toBeUndefined(); + }); + it('rotates refresh tokens and rejects reuse', async () => { const loginResponse = await loginWithSso(); const refreshResponse = await authService.refresh({ @@ -183,4 +314,16 @@ describe('AuthService', () => { async function loginWithSso(): Promise { return authService.completeSsoLogin('code', 'state'); } + + async function createUser(id: string, email: string): Promise { + return (await usersRepository.save( + usersRepository.create({ + id, + email, + name: 'Target User', + onboardingCompleted: false, + taskDigestPreference: 'both', + }), + )) as UserEntity; + } }); diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index e813bfc..aa4d10a 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -24,6 +24,8 @@ import { import type { TaskDigestPreference } from '../tasks/task-digest.types'; import { OidcProfile, OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; +import { UserKeycloakGroupEntity } from './user-keycloak-group.entity'; +import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; @Injectable() @@ -42,6 +44,10 @@ export class AuthService { private readonly usersRepository: Repository, @InjectRepository(RefreshTokenEntity) private readonly refreshTokensRepository: Repository, + @InjectRepository(UserKeycloakGroupEntity) + private readonly userKeycloakGroupsRepository: Repository, + @InjectRepository(UserImpersonationEntity) + private readonly userImpersonationsRepository: Repository, @Optional() private readonly auditLogService?: AuditLogService, ) {} @@ -89,9 +95,12 @@ export class AuthService { where: { email: this.normalizeEmail(profile.email) }, })); const user = await this.syncOidcUser(profile, existingUser); + await this.syncKeycloakGroups(user.id, profile.groups); const response = { ...(await this.createAuthTokens(user)), - user: this.toPublicUser(user), + user: await this.toPublicUserWithGroups( + await this.resolveEffectiveUser(user), + ), }; await this.auditLogService?.record({ @@ -136,7 +145,9 @@ export class AuthService { const response = { ...(await this.createAuthTokens(user)), - user: this.toPublicUser(user), + user: await this.toPublicUserWithGroups( + await this.resolveEffectiveUser(user), + ), }; await this.auditLogService?.record({ @@ -168,7 +179,19 @@ export class AuthService { throw new UnauthorizedException('Access token is invalid.'); } - return payload; + const effectiveUser = await this.resolveEffectiveUser(user); + + if (effectiveUser.id === user.id) { + return payload; + } + + return { + ...payload, + sub: effectiveUser.id, + email: effectiveUser.email, + impersonatorSub: user.id, + impersonatorEmail: user.email, + }; } catch { throw new UnauthorizedException('Access token is invalid.'); } @@ -195,7 +218,7 @@ export class AuthService { throw new UnauthorizedException('Authenticated user is required.'); } - return this.toPublicUser(user); + return this.toPublicUserWithGroups(user); } async searchUsers( @@ -255,7 +278,7 @@ export class AuthService { metadata: { completed }, }); - return this.toPublicUser(savedUser); + return this.toPublicUserWithGroups(savedUser); } async updateTaskDigestPreference( @@ -283,7 +306,7 @@ export class AuthService { metadata: { taskDigestPreference: savedUser.taskDigestPreference }, }); - return this.toPublicUser(savedUser); + return this.toPublicUserWithGroups(savedUser); } private normalizeEmail(email?: string): string { @@ -333,6 +356,77 @@ export class AuthService { return this.usersRepository.save(user); } + private async resolveEffectiveUser(user: UserEntity): Promise { + const now = Date.now(); + const impersonations = await this.userImpersonationsRepository.find({ + where: { impersonatorUserId: user.id, enabled: true }, + order: { createdAt: 'DESC' }, + }); + const activeImpersonation = impersonations.find( + (impersonation) => + !impersonation.expiresAt || impersonation.expiresAt.getTime() > now, + ); + + if (!activeImpersonation) { + return user; + } + + const targetUser = await this.usersRepository.findOne({ + where: { id: activeImpersonation.targetUserId }, + }); + + if (!targetUser) { + throw new UnauthorizedException('Impersonated user does not exist.'); + } + + return targetUser; + } + + private async syncKeycloakGroups( + userId: string, + groups: string[], + ): Promise { + const normalizedGroups = this.normalizeGroupPaths(groups); + + await this.userKeycloakGroupsRepository.delete({ userId }); + + if (!normalizedGroups.length) { + return; + } + + await this.userKeycloakGroupsRepository.save( + normalizedGroups.map((groupPath) => + this.userKeycloakGroupsRepository.create({ + id: randomUUID(), + userId, + groupPath, + groupName: this.groupNameFromPath(groupPath), + }), + ), + ); + } + + private normalizeGroupPaths(groups: string[]): string[] { + return [...new Set(groups)] + .map((group) => group.trim()) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); + } + + private groupNameFromPath(groupPath: string): string { + return groupPath.split('/').filter(Boolean).at(-1) ?? groupPath; + } + + private async getUserGroupPaths(userId: string): Promise { + const groups = await this.userKeycloakGroupsRepository.find({ + where: { userId }, + }); + + return groups + .map((group) => group.groupPath) + .sort((left, right) => left.localeCompare(right)); + } + private secretMatches(secret: string, storedSecretHash: string): boolean { const [salt, storedHash] = storedSecretHash.split(':'); @@ -419,13 +513,18 @@ export class AuthService { return this.secretMatches(token, tokenHash); } - private toPublicUser(user: UserEntity): PublicUser { + private async toPublicUserWithGroups(user: UserEntity): Promise { + return this.toPublicUser(user, await this.getUserGroupPaths(user.id)); + } + + private toPublicUser(user: UserEntity, groups: string[] = []): PublicUser { return { id: user.id, email: user.email, name: user.name ?? undefined, onboardingCompleted: user.onboardingCompleted === true, taskDigestPreference: user.taskDigestPreference ?? 'both', + groups, }; } } diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index 2e6e4ba..0777638 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -15,6 +15,8 @@ export interface JwtTokenPayload { email: string; type: 'access' | 'refresh'; jti?: string; + impersonatorSub?: string; + impersonatorEmail?: string; } export interface AuthenticatedRequest extends Request { @@ -27,6 +29,7 @@ export interface PublicUser { name?: string; onboardingCompleted: boolean; taskDigestPreference: TaskDigestPreference; + groups: string[]; } export interface PublicUserSearchResult { diff --git a/listify-api/src/auth/jwt-auth.guard.spec.ts b/listify-api/src/auth/jwt-auth.guard.spec.ts index db1df6d..7f532e2 100644 --- a/listify-api/src/auth/jwt-auth.guard.spec.ts +++ b/listify-api/src/auth/jwt-auth.guard.spec.ts @@ -8,6 +8,8 @@ import { AuthenticatedRequest } from './auth.types'; import { JwtAuthGuard } from './jwt-auth.guard'; import { OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; +import { UserKeycloakGroupEntity } from './user-keycloak-group.entity'; +import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; import { InMemoryRepository } from '../testing/in-memory-repository'; @@ -32,6 +34,7 @@ describe('JwtAuthGuard', () => { subject: 'oidc-user-1', email: 'user@example.com', name: 'Test User', + groups: [], })), }, }, @@ -43,6 +46,14 @@ describe('JwtAuthGuard', () => { provide: getRepositoryToken(RefreshTokenEntity), useValue: new InMemoryRepository(), }, + { + provide: getRepositoryToken(UserKeycloakGroupEntity), + useValue: new InMemoryRepository(), + }, + { + provide: getRepositoryToken(UserImpersonationEntity), + useValue: new InMemoryRepository(), + }, ], }).compile(); await module.init(); diff --git a/listify-api/src/auth/oidc.service.ts b/listify-api/src/auth/oidc.service.ts index 0e4f308..962b1a4 100644 --- a/listify-api/src/auth/oidc.service.ts +++ b/listify-api/src/auth/oidc.service.ts @@ -12,11 +12,13 @@ export interface OidcProfile { subject: string; email: string; name?: string; + groups: string[]; } interface OidcDiscovery { authorization_endpoint: string; token_endpoint: string; + userinfo_endpoint?: string; jwks_uri: string; issuer: string; } @@ -29,6 +31,7 @@ interface PendingOidcState { interface TokenResponse { id_token?: string; + access_token?: string; error?: string; error_description?: string; } @@ -59,7 +62,7 @@ export class OidcService { authorizationUrl.searchParams.set('response_type', 'code'); authorizationUrl.searchParams.set('client_id', config.clientId); authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl); - authorizationUrl.searchParams.set('scope', 'openid email profile'); + authorizationUrl.searchParams.set('scope', config.scope); authorizationUrl.searchParams.set('state', state); authorizationUrl.searchParams.set('nonce', nonce); authorizationUrl.searchParams.set('code_challenge', codeChallenge); @@ -89,6 +92,7 @@ export class OidcService { pendingState.codeVerifier, ); + console.log(tokenResponse) if (!tokenResponse.id_token) { throw new ServiceUnavailableException( tokenResponse.error_description ?? @@ -120,10 +124,19 @@ export class OidcService { throw new BadRequestException('OIDC email claim is missing.'); } + const idTokenGroups = this.extractGroups(payload, config.groupsClaim); + return { subject: payload.sub, email, name: typeof payload.name === 'string' ? payload.name : undefined, + groups: idTokenGroups.length + ? idTokenGroups + : await this.requestUserInfoGroups( + discovery.userinfo_endpoint, + tokenResponse.access_token, + config.groupsClaim, + ), }; } @@ -210,9 +223,53 @@ export class OidcService { clientId, callbackUrl, clientSecret: process.env.OIDC_CLIENT_SECRET, + scope: process.env.OIDC_SCOPE ?? 'openid email profile', + groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups', }; } + private async requestUserInfoGroups( + userInfoEndpoint: string | undefined, + accessToken: string | undefined, + groupsClaim: string, + ): Promise { + if (!userInfoEndpoint || !accessToken) { + return []; + } + + const response = await fetch(userInfoEndpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + return []; + } + + const payload = (await response.json().catch(() => ({}))) as Record< + string, + unknown + >; + + return this.extractGroups(payload, groupsClaim); + } + + private extractGroups( + payload: Record, + groupsClaim: string, + ): string[] { + const claimValue = payload[groupsClaim]; + + if (!Array.isArray(claimValue)) { + return []; + } + + return [...new Set(claimValue)] + .filter((group): group is string => typeof group === 'string') + .map((group) => group.trim()) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); + } + private createOpaqueToken(): string { return randomBytes(32).toString('base64url'); } diff --git a/listify-api/src/auth/user-impersonation.entity.ts b/listify-api/src/auth/user-impersonation.entity.ts new file mode 100644 index 0000000..146a69b --- /dev/null +++ b/listify-api/src/auth/user-impersonation.entity.ts @@ -0,0 +1,61 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from './user.entity'; + +@Entity('user_impersonations') +@Index('IDX_user_impersonations_impersonator_enabled', [ + 'impersonatorUserId', + 'enabled', +]) +export class UserImpersonationEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + impersonatorUserId!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + targetUserId!: string; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; + + @Column({ type: 'varchar', length: 255, nullable: true }) + reason?: string | null; + + @Column({ type: 'datetime', precision: 3, nullable: true }) + expiresAt?: Date | null; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; + + @UpdateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + onUpdate: 'CURRENT_TIMESTAMP(3)', + }) + updatedAt!: Date; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'impersonatorUserId' }) + impersonator?: UserEntity; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'targetUserId' }) + target?: UserEntity; +} diff --git a/listify-api/src/auth/user-keycloak-group.entity.ts b/listify-api/src/auth/user-keycloak-group.entity.ts new file mode 100644 index 0000000..0c017a2 --- /dev/null +++ b/listify-api/src/auth/user-keycloak-group.entity.ts @@ -0,0 +1,49 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from './user.entity'; + +@Entity('user_keycloak_groups') +@Index('IDX_user_keycloak_groups_user_group', ['userId', 'groupPath'], { + unique: true, +}) +export class UserKeycloakGroupEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + userId!: string; + + @Column({ type: 'varchar', length: 255 }) + groupPath!: string; + + @Column({ type: 'varchar', length: 160 }) + groupName!: string; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; + + @UpdateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + onUpdate: 'CURRENT_TIMESTAMP(3)', + }) + updatedAt!: Date; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user?: UserEntity; +} diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index 992484d..6227cad 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -4,6 +4,8 @@ import { DataSource } from 'typeorm'; import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity'; import { AuditLogEntity } from '../audit/audit-log.entity'; import { UserEntity } from '../auth/user.entity'; +import { UserKeycloakGroupEntity } from '../auth/user-keycloak-group.entity'; +import { UserImpersonationEntity } from '../auth/user-impersonation.entity'; import { RefreshTokenEntity } from '../auth/refresh-token.entity'; import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity'; import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity'; @@ -38,6 +40,8 @@ export default new DataSource({ AuditLogEntity, DailyDashboardSnapshotEntity, UserEntity, + UserKeycloakGroupEntity, + UserImpersonationEntity, RefreshTokenEntity, ListTemplateEntity, ListTemplateItemEntity, diff --git a/listify-api/src/database/migrations/1782300000000-CreateUserImpersonations.ts b/listify-api/src/database/migrations/1782300000000-CreateUserImpersonations.ts new file mode 100644 index 0000000..9de4cb5 --- /dev/null +++ b/listify-api/src/database/migrations/1782300000000-CreateUserImpersonations.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserImpersonations1782300000000 implements MigrationInterface { + name = 'CreateUserImpersonations1782300000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('user_impersonations')) { + return; + } + + await queryRunner.query(` + CREATE TABLE \`user_impersonations\` ( + \`id\` varchar(36) NOT NULL, + \`impersonatorUserId\` varchar(36) NOT NULL, + \`targetUserId\` varchar(36) NOT NULL, + \`enabled\` tinyint NOT NULL DEFAULT 1, + \`reason\` varchar(255) NULL, + \`expiresAt\` datetime(3) NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + \`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + INDEX \`IDX_user_impersonations_impersonatorUserId\` (\`impersonatorUserId\`), + INDEX \`IDX_user_impersonations_targetUserId\` (\`targetUserId\`), + INDEX \`IDX_user_impersonations_impersonator_enabled\` (\`impersonatorUserId\`, \`enabled\`), + CONSTRAINT \`FK_user_impersonations_impersonator_user\` + FOREIGN KEY (\`impersonatorUserId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE, + CONSTRAINT \`FK_user_impersonations_target_user\` + FOREIGN KEY (\`targetUserId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('user_impersonations')) { + await queryRunner.query('DROP TABLE `user_impersonations`'); + } + } +} diff --git a/listify-api/src/database/migrations/1782400000000-CreateUserKeycloakGroups.ts b/listify-api/src/database/migrations/1782400000000-CreateUserKeycloakGroups.ts new file mode 100644 index 0000000..74387e4 --- /dev/null +++ b/listify-api/src/database/migrations/1782400000000-CreateUserKeycloakGroups.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface { + name = 'CreateUserKeycloakGroups1782400000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('user_keycloak_groups')) { + return; + } + + await queryRunner.query(` + CREATE TABLE \`user_keycloak_groups\` ( + \`id\` varchar(36) NOT NULL, + \`userId\` varchar(36) NOT NULL, + \`groupPath\` varchar(255) NOT NULL, + \`groupName\` varchar(160) NOT NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + \`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + INDEX \`IDX_user_keycloak_groups_userId\` (\`userId\`), + UNIQUE INDEX \`IDX_user_keycloak_groups_user_group\` (\`userId\`, \`groupPath\`), + CONSTRAINT \`FK_user_keycloak_groups_user\` + FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('user_keycloak_groups')) { + await queryRunner.query('DROP TABLE `user_keycloak_groups`'); + } + } +} diff --git a/listify-api/test/app.e2e-spec.ts b/listify-api/test/app.e2e-spec.ts index e2d9215..7417916 100644 --- a/listify-api/test/app.e2e-spec.ts +++ b/listify-api/test/app.e2e-spec.ts @@ -45,6 +45,7 @@ describe('AppController (e2e)', () => { subject: 'oidc-default', email: 'default@example.com', name: 'Default User', + groups: [], })), }; @@ -205,9 +206,7 @@ describe('AppController (e2e)', () => { expect(fetchedList.items[0].checked).toBe(true); }); - async function loginWithSsoAndGetAccessToken( - email: string, - ): Promise { + async function loginWithSsoAndGetAccessToken(email: string): Promise { const loginBody = await loginWithSso(email); expect(loginBody.accessToken).toBeDefined(); @@ -219,6 +218,7 @@ describe('AppController (e2e)', () => { subject: `sub-${email}`, email, name: 'Test User', + groups: [], }); const exchangeResponse = await request(app.getHttpServer()) @@ -270,7 +270,9 @@ describe('AppController (e2e)', () => { )) as unknown[]; if (columns.length) { - await dataSource.query(`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``); + await dataSource.query( + `ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``, + ); } } diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index a1a27f0..9cf477e 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -18,6 +18,26 @@ +
+
+ +
+

Keycloak-Gruppen

+

{{ auth.user()?.groups?.length || 0 }} synchronisiert

+
+
+ + @if (auth.user()?.groups?.length) { +
    + @for (group of auth.user()?.groups ?? []; track group) { +
  • {{ group }}
  • + } +
+ } @else { +

Keine Gruppen hinterlegt.

+ } +
+
diff --git a/listify-client/src/app/account/account.component.scss b/listify-client/src/app/account/account.component.scss index 8f0a550..43df924 100644 --- a/listify-client/src/app/account/account.component.scss +++ b/listify-client/src/app/account/account.component.scss @@ -40,6 +40,16 @@ background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface)); } +.groups-section { + display: grid; + gap: 0.8rem; + margin-top: 1rem; + padding: 0.9rem; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface)); +} + .settings-heading { display: flex; gap: 0.75rem; @@ -72,6 +82,26 @@ width: 100%; } +.group-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.group-list li { + max-width: 100%; + padding: 0.35rem 0.6rem; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 999px; + background: var(--mat-sys-surface); + color: var(--mat-sys-on-surface-variant); + font-size: 0.88rem; + overflow-wrap: anywhere; +} + .saving-row { display: inline-flex; align-items: center; diff --git a/listify-client/src/app/auth/auth.models.ts b/listify-client/src/app/auth/auth.models.ts index 3e78b23..fd6292f 100644 --- a/listify-client/src/app/auth/auth.models.ts +++ b/listify-client/src/app/auth/auth.models.ts @@ -6,6 +6,7 @@ export interface PublicUser { name?: string; onboardingCompleted: boolean; taskDigestPreference: TaskDigestPreference; + groups: string[]; } export interface PublicUserSearchResult { diff --git a/listify-client/src/app/auth/auth.service.ts b/listify-client/src/app/auth/auth.service.ts index 8820b11..e885190 100644 --- a/listify-client/src/app/auth/auth.service.ts +++ b/listify-client/src/app/auth/auth.service.ts @@ -116,8 +116,9 @@ export class AuthService { } private storeUser(user: PublicUser): void { - this.storage?.setItem(USER_KEY, JSON.stringify(user)); - this.userSignal.set(user); + const normalizedUser = this.normalizeUser(user); + this.storage?.setItem(USER_KEY, JSON.stringify(normalizedUser)); + this.userSignal.set(normalizedUser); } private readStoredUser(): PublicUser | null { @@ -128,13 +129,20 @@ export class AuthService { } try { - return JSON.parse(rawUser) as PublicUser; + return this.normalizeUser(JSON.parse(rawUser) as PublicUser); } catch { this.storage?.removeItem(USER_KEY); return null; } } + private normalizeUser(user: PublicUser): PublicUser { + return { + ...user, + groups: Array.isArray(user.groups) ? user.groups : [], + }; + } + private get storage(): Storage | null { return typeof window === 'undefined' ? null : window.localStorage; }