groups und impersination

This commit is contained in:
Bastian Wagner
2026-07-14 18:09:43 +02:00
parent f45583f3ea
commit 0dfba43aa6
16 changed files with 583 additions and 17 deletions

View File

@@ -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],

View File

@@ -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<UserEntity>;
let userKeycloakGroupsRepository: InMemoryRepository<UserKeycloakGroupEntity>;
let userImpersonationsRepository: InMemoryRepository<UserImpersonationEntity>;
beforeEach(async () => {
oidcService = new FakeOidcService();
usersRepository = new InMemoryRepository<UserEntity>();
userKeycloakGroupsRepository =
new InMemoryRepository<UserKeycloakGroupEntity>();
userImpersonationsRepository =
new InMemoryRepository<UserImpersonationEntity>();
module = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
providers: [
@@ -40,12 +51,20 @@ describe('AuthService', () => {
},
{
provide: getRepositoryToken(UserEntity),
useValue: new InMemoryRepository<UserEntity>(),
useValue: usersRepository,
},
{
provide: getRepositoryToken(RefreshTokenEntity),
useValue: new InMemoryRepository<RefreshTokenEntity>(),
},
{
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<AuthTokenResponse> {
return authService.completeSsoLogin('code', 'state');
}
async function createUser(id: string, email: string): Promise<UserEntity> {
return (await usersRepository.save(
usersRepository.create({
id,
email,
name: 'Target User',
onboardingCompleted: false,
taskDigestPreference: 'both',
}),
)) as UserEntity;
}
});

View File

@@ -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<UserEntity>,
@InjectRepository(RefreshTokenEntity)
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
@InjectRepository(UserKeycloakGroupEntity)
private readonly userKeycloakGroupsRepository: Repository<UserKeycloakGroupEntity>,
@InjectRepository(UserImpersonationEntity)
private readonly userImpersonationsRepository: Repository<UserImpersonationEntity>,
@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.');
}
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<UserEntity> {
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<void> {
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<string[]> {
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<PublicUser> {
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,
};
}
}

View File

@@ -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 {

View File

@@ -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<RefreshTokenEntity>(),
},
{
provide: getRepositoryToken(UserKeycloakGroupEntity),
useValue: new InMemoryRepository<UserKeycloakGroupEntity>(),
},
{
provide: getRepositoryToken(UserImpersonationEntity),
useValue: new InMemoryRepository<UserImpersonationEntity>(),
},
],
}).compile();
await module.init();

View File

@@ -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<string[]> {
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<string, unknown>,
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');
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserImpersonations1782300000000 implements MigrationInterface {
name = 'CreateUserImpersonations1782300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
if (await queryRunner.hasTable('user_impersonations')) {
await queryRunner.query('DROP TABLE `user_impersonations`');
}
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface {
name = 'CreateUserKeycloakGroups1782400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
if (await queryRunner.hasTable('user_keycloak_groups')) {
await queryRunner.query('DROP TABLE `user_keycloak_groups`');
}
}
}

View File

@@ -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<string> {
async function loginWithSsoAndGetAccessToken(email: string): Promise<string> {
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}\``,
);
}
}

View File

@@ -18,6 +18,26 @@
</span>
</div>
<section class="groups-section" aria-label="Keycloak Gruppen">
<div class="settings-heading">
<mat-icon aria-hidden="true">groups</mat-icon>
<div>
<h2>Keycloak-Gruppen</h2>
<p>{{ auth.user()?.groups?.length || 0 }} synchronisiert</p>
</div>
</div>
@if (auth.user()?.groups?.length) {
<ul class="group-list">
@for (group of auth.user()?.groups ?? []; track group) {
<li>{{ group }}</li>
}
</ul>
} @else {
<p class="settings-description">Keine Gruppen hinterlegt.</p>
}
</section>
<section class="settings-section" aria-label="Task-Mail Einstellungen">
<div class="settings-heading">
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>

View File

@@ -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;

View File

@@ -6,6 +6,7 @@ export interface PublicUser {
name?: string;
onboardingCompleted: boolean;
taskDigestPreference: TaskDigestPreference;
groups: string[];
}
export interface PublicUserSearchResult {

View File

@@ -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;
}