groups und impersination
This commit is contained in:
@@ -8,13 +8,20 @@ import { AuthService } from './auth.service';
|
|||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { McpAuthGuard } from './mcp-auth.guard';
|
import { McpAuthGuard } from './mcp-auth.guard';
|
||||||
import { OidcService } from './oidc.service';
|
import { OidcService } from './oidc.service';
|
||||||
|
import { UserKeycloakGroupEntity } from './user-keycloak-group.entity';
|
||||||
|
import { UserImpersonationEntity } from './user-impersonation.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AuditModule,
|
AuditModule,
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
|
TypeOrmModule.forFeature([
|
||||||
|
UserEntity,
|
||||||
|
RefreshTokenEntity,
|
||||||
|
UserKeycloakGroupEntity,
|
||||||
|
UserImpersonationEntity,
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard],
|
providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard],
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { OidcProfile, OidcService } from './oidc.service';
|
import { OidcProfile, OidcService } from './oidc.service';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
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 { UserEntity } from './user.entity';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ class FakeOidcService {
|
|||||||
subject: 'oidc-user-1',
|
subject: 'oidc-user-1',
|
||||||
email: 'User@Example.com',
|
email: 'User@Example.com',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
createAuthorizationUrl = jest.fn(
|
createAuthorizationUrl = jest.fn(
|
||||||
@@ -27,9 +30,17 @@ describe('AuthService', () => {
|
|||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
let jwtService: JwtService;
|
let jwtService: JwtService;
|
||||||
let oidcService: FakeOidcService;
|
let oidcService: FakeOidcService;
|
||||||
|
let usersRepository: InMemoryRepository<UserEntity>;
|
||||||
|
let userKeycloakGroupsRepository: InMemoryRepository<UserKeycloakGroupEntity>;
|
||||||
|
let userImpersonationsRepository: InMemoryRepository<UserImpersonationEntity>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
oidcService = new FakeOidcService();
|
oidcService = new FakeOidcService();
|
||||||
|
usersRepository = new InMemoryRepository<UserEntity>();
|
||||||
|
userKeycloakGroupsRepository =
|
||||||
|
new InMemoryRepository<UserKeycloakGroupEntity>();
|
||||||
|
userImpersonationsRepository =
|
||||||
|
new InMemoryRepository<UserImpersonationEntity>();
|
||||||
module = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -40,12 +51,20 @@ describe('AuthService', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserEntity),
|
provide: getRepositoryToken(UserEntity),
|
||||||
useValue: new InMemoryRepository<UserEntity>(),
|
useValue: usersRepository,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(RefreshTokenEntity),
|
provide: getRepositoryToken(RefreshTokenEntity),
|
||||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
||||||
|
useValue: userKeycloakGroupsRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(UserImpersonationEntity),
|
||||||
|
useValue: userImpersonationsRepository,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
await module.init();
|
await module.init();
|
||||||
@@ -81,6 +100,7 @@ describe('AuthService', () => {
|
|||||||
subject: 'oidc-user-1',
|
subject: 'oidc-user-1',
|
||||||
email: 'renamed@example.com',
|
email: 'renamed@example.com',
|
||||||
name: 'Renamed User',
|
name: 'Renamed User',
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
@@ -96,6 +116,7 @@ describe('AuthService', () => {
|
|||||||
subject: 'oidc-user-2',
|
subject: 'oidc-user-2',
|
||||||
email: 'User@Example.com',
|
email: 'User@Example.com',
|
||||||
name: 'Linked User',
|
name: 'Linked User',
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
@@ -129,6 +150,116 @@ describe('AuthService', () => {
|
|||||||
expect(refreshPayload.jti).toBeDefined();
|
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 () => {
|
it('rotates refresh tokens and rejects reuse', async () => {
|
||||||
const loginResponse = await loginWithSso();
|
const loginResponse = await loginWithSso();
|
||||||
const refreshResponse = await authService.refresh({
|
const refreshResponse = await authService.refresh({
|
||||||
@@ -183,4 +314,16 @@ describe('AuthService', () => {
|
|||||||
async function loginWithSso(): Promise<AuthTokenResponse> {
|
async function loginWithSso(): Promise<AuthTokenResponse> {
|
||||||
return authService.completeSsoLogin('code', 'state');
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||||
import { OidcProfile, OidcService } from './oidc.service';
|
import { OidcProfile, OidcService } from './oidc.service';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
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 { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -42,6 +44,10 @@ export class AuthService {
|
|||||||
private readonly usersRepository: Repository<UserEntity>,
|
private readonly usersRepository: Repository<UserEntity>,
|
||||||
@InjectRepository(RefreshTokenEntity)
|
@InjectRepository(RefreshTokenEntity)
|
||||||
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
||||||
|
@InjectRepository(UserKeycloakGroupEntity)
|
||||||
|
private readonly userKeycloakGroupsRepository: Repository<UserKeycloakGroupEntity>,
|
||||||
|
@InjectRepository(UserImpersonationEntity)
|
||||||
|
private readonly userImpersonationsRepository: Repository<UserImpersonationEntity>,
|
||||||
@Optional()
|
@Optional()
|
||||||
private readonly auditLogService?: AuditLogService,
|
private readonly auditLogService?: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
@@ -89,9 +95,12 @@ export class AuthService {
|
|||||||
where: { email: this.normalizeEmail(profile.email) },
|
where: { email: this.normalizeEmail(profile.email) },
|
||||||
}));
|
}));
|
||||||
const user = await this.syncOidcUser(profile, existingUser);
|
const user = await this.syncOidcUser(profile, existingUser);
|
||||||
|
await this.syncKeycloakGroups(user.id, profile.groups);
|
||||||
const response = {
|
const response = {
|
||||||
...(await this.createAuthTokens(user)),
|
...(await this.createAuthTokens(user)),
|
||||||
user: this.toPublicUser(user),
|
user: await this.toPublicUserWithGroups(
|
||||||
|
await this.resolveEffectiveUser(user),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
await this.auditLogService?.record({
|
||||||
@@ -136,7 +145,9 @@ export class AuthService {
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
...(await this.createAuthTokens(user)),
|
...(await this.createAuthTokens(user)),
|
||||||
user: this.toPublicUser(user),
|
user: await this.toPublicUserWithGroups(
|
||||||
|
await this.resolveEffectiveUser(user),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
await this.auditLogService?.record({
|
||||||
@@ -168,7 +179,19 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Access token is invalid.');
|
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 {
|
} catch {
|
||||||
throw new UnauthorizedException('Access token is invalid.');
|
throw new UnauthorizedException('Access token is invalid.');
|
||||||
}
|
}
|
||||||
@@ -195,7 +218,7 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Authenticated user is required.');
|
throw new UnauthorizedException('Authenticated user is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toPublicUser(user);
|
return this.toPublicUserWithGroups(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchUsers(
|
async searchUsers(
|
||||||
@@ -255,7 +278,7 @@ export class AuthService {
|
|||||||
metadata: { completed },
|
metadata: { completed },
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.toPublicUser(savedUser);
|
return this.toPublicUserWithGroups(savedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTaskDigestPreference(
|
async updateTaskDigestPreference(
|
||||||
@@ -283,7 +306,7 @@ export class AuthService {
|
|||||||
metadata: { taskDigestPreference: savedUser.taskDigestPreference },
|
metadata: { taskDigestPreference: savedUser.taskDigestPreference },
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.toPublicUser(savedUser);
|
return this.toPublicUserWithGroups(savedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeEmail(email?: string): string {
|
private normalizeEmail(email?: string): string {
|
||||||
@@ -333,6 +356,77 @@ export class AuthService {
|
|||||||
return this.usersRepository.save(user);
|
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 {
|
private secretMatches(secret: string, storedSecretHash: string): boolean {
|
||||||
const [salt, storedHash] = storedSecretHash.split(':');
|
const [salt, storedHash] = storedSecretHash.split(':');
|
||||||
|
|
||||||
@@ -419,13 +513,18 @@ export class AuthService {
|
|||||||
return this.secretMatches(token, tokenHash);
|
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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name ?? undefined,
|
name: user.name ?? undefined,
|
||||||
onboardingCompleted: user.onboardingCompleted === true,
|
onboardingCompleted: user.onboardingCompleted === true,
|
||||||
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
||||||
|
groups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface JwtTokenPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
jti?: string;
|
jti?: string;
|
||||||
|
impersonatorSub?: string;
|
||||||
|
impersonatorEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticatedRequest extends Request {
|
export interface AuthenticatedRequest extends Request {
|
||||||
@@ -27,6 +29,7 @@ export interface PublicUser {
|
|||||||
name?: string;
|
name?: string;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
taskDigestPreference: TaskDigestPreference;
|
taskDigestPreference: TaskDigestPreference;
|
||||||
|
groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicUserSearchResult {
|
export interface PublicUserSearchResult {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { AuthenticatedRequest } from './auth.types';
|
|||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { OidcService } from './oidc.service';
|
import { OidcService } from './oidc.service';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
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 { UserEntity } from './user.entity';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ describe('JwtAuthGuard', () => {
|
|||||||
subject: 'oidc-user-1',
|
subject: 'oidc-user-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
|
groups: [],
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -43,6 +46,14 @@ describe('JwtAuthGuard', () => {
|
|||||||
provide: getRepositoryToken(RefreshTokenEntity),
|
provide: getRepositoryToken(RefreshTokenEntity),
|
||||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
||||||
|
useValue: new InMemoryRepository<UserKeycloakGroupEntity>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(UserImpersonationEntity),
|
||||||
|
useValue: new InMemoryRepository<UserImpersonationEntity>(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
await module.init();
|
await module.init();
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ export interface OidcProfile {
|
|||||||
subject: string;
|
subject: string;
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OidcDiscovery {
|
interface OidcDiscovery {
|
||||||
authorization_endpoint: string;
|
authorization_endpoint: string;
|
||||||
token_endpoint: string;
|
token_endpoint: string;
|
||||||
|
userinfo_endpoint?: string;
|
||||||
jwks_uri: string;
|
jwks_uri: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
}
|
}
|
||||||
@@ -29,6 +31,7 @@ interface PendingOidcState {
|
|||||||
|
|
||||||
interface TokenResponse {
|
interface TokenResponse {
|
||||||
id_token?: string;
|
id_token?: string;
|
||||||
|
access_token?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
error_description?: string;
|
error_description?: string;
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,7 @@ export class OidcService {
|
|||||||
authorizationUrl.searchParams.set('response_type', 'code');
|
authorizationUrl.searchParams.set('response_type', 'code');
|
||||||
authorizationUrl.searchParams.set('client_id', config.clientId);
|
authorizationUrl.searchParams.set('client_id', config.clientId);
|
||||||
authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl);
|
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('state', state);
|
||||||
authorizationUrl.searchParams.set('nonce', nonce);
|
authorizationUrl.searchParams.set('nonce', nonce);
|
||||||
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
@@ -89,6 +92,7 @@ export class OidcService {
|
|||||||
pendingState.codeVerifier,
|
pendingState.codeVerifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(tokenResponse)
|
||||||
if (!tokenResponse.id_token) {
|
if (!tokenResponse.id_token) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
tokenResponse.error_description ??
|
tokenResponse.error_description ??
|
||||||
@@ -120,10 +124,19 @@ export class OidcService {
|
|||||||
throw new BadRequestException('OIDC email claim is missing.');
|
throw new BadRequestException('OIDC email claim is missing.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const idTokenGroups = this.extractGroups(payload, config.groupsClaim);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: payload.sub,
|
subject: payload.sub,
|
||||||
email,
|
email,
|
||||||
name: typeof payload.name === 'string' ? payload.name : undefined,
|
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,
|
clientId,
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
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 {
|
private createOpaqueToken(): string {
|
||||||
return randomBytes(32).toString('base64url');
|
return randomBytes(32).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|||||||
61
listify-api/src/auth/user-impersonation.entity.ts
Normal file
61
listify-api/src/auth/user-impersonation.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
49
listify-api/src/auth/user-keycloak-group.entity.ts
Normal file
49
listify-api/src/auth/user-keycloak-group.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { DataSource } from 'typeorm';
|
|||||||
import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
||||||
import { AuditLogEntity } from '../audit/audit-log.entity';
|
import { AuditLogEntity } from '../audit/audit-log.entity';
|
||||||
import { UserEntity } from '../auth/user.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 { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
||||||
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
|
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
|
||||||
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
|
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
|
||||||
@@ -38,6 +40,8 @@ export default new DataSource({
|
|||||||
AuditLogEntity,
|
AuditLogEntity,
|
||||||
DailyDashboardSnapshotEntity,
|
DailyDashboardSnapshotEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
|
UserKeycloakGroupEntity,
|
||||||
|
UserImpersonationEntity,
|
||||||
RefreshTokenEntity,
|
RefreshTokenEntity,
|
||||||
ListTemplateEntity,
|
ListTemplateEntity,
|
||||||
ListTemplateItemEntity,
|
ListTemplateItemEntity,
|
||||||
|
|||||||
@@ -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`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ describe('AppController (e2e)', () => {
|
|||||||
subject: 'oidc-default',
|
subject: 'oidc-default',
|
||||||
email: 'default@example.com',
|
email: 'default@example.com',
|
||||||
name: 'Default User',
|
name: 'Default User',
|
||||||
|
groups: [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,9 +206,7 @@ describe('AppController (e2e)', () => {
|
|||||||
expect(fetchedList.items[0].checked).toBe(true);
|
expect(fetchedList.items[0].checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loginWithSsoAndGetAccessToken(
|
async function loginWithSsoAndGetAccessToken(email: string): Promise<string> {
|
||||||
email: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const loginBody = await loginWithSso(email);
|
const loginBody = await loginWithSso(email);
|
||||||
expect(loginBody.accessToken).toBeDefined();
|
expect(loginBody.accessToken).toBeDefined();
|
||||||
|
|
||||||
@@ -219,6 +218,7 @@ describe('AppController (e2e)', () => {
|
|||||||
subject: `sub-${email}`,
|
subject: `sub-${email}`,
|
||||||
email,
|
email,
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
|
groups: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const exchangeResponse = await request(app.getHttpServer())
|
const exchangeResponse = await request(app.getHttpServer())
|
||||||
@@ -270,7 +270,9 @@ describe('AppController (e2e)', () => {
|
|||||||
)) as unknown[];
|
)) as unknown[];
|
||||||
|
|
||||||
if (columns.length) {
|
if (columns.length) {
|
||||||
await dataSource.query(`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``);
|
await dataSource.query(
|
||||||
|
`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,26 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<section class="settings-section" aria-label="Task-Mail Einstellungen">
|
||||||
<div class="settings-heading">
|
<div class="settings-heading">
|
||||||
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
||||||
|
|||||||
@@ -40,6 +40,16 @@
|
|||||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
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 {
|
.settings-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -72,6 +82,26 @@
|
|||||||
width: 100%;
|
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 {
|
.saving-row {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface PublicUser {
|
|||||||
name?: string;
|
name?: string;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
taskDigestPreference: TaskDigestPreference;
|
taskDigestPreference: TaskDigestPreference;
|
||||||
|
groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicUserSearchResult {
|
export interface PublicUserSearchResult {
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private storeUser(user: PublicUser): void {
|
private storeUser(user: PublicUser): void {
|
||||||
this.storage?.setItem(USER_KEY, JSON.stringify(user));
|
const normalizedUser = this.normalizeUser(user);
|
||||||
this.userSignal.set(user);
|
this.storage?.setItem(USER_KEY, JSON.stringify(normalizedUser));
|
||||||
|
this.userSignal.set(normalizedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readStoredUser(): PublicUser | null {
|
private readStoredUser(): PublicUser | null {
|
||||||
@@ -128,13 +129,20 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(rawUser) as PublicUser;
|
return this.normalizeUser(JSON.parse(rawUser) as PublicUser);
|
||||||
} catch {
|
} catch {
|
||||||
this.storage?.removeItem(USER_KEY);
|
this.storage?.removeItem(USER_KEY);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeUser(user: PublicUser): PublicUser {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
groups: Array.isArray(user.groups) ? user.groups : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private get storage(): Storage | null {
|
private get storage(): Storage | null {
|
||||||
return typeof window === 'undefined' ? null : window.localStorage;
|
return typeof window === 'undefined' ? null : window.localStorage;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user