auth
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npm list *)",
|
||||
"Bash(./node_modules/.bin/ng test *)",
|
||||
"Bash(npx ng *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideAppInitializer } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { KeycloakService } from './auth/keycloak.service';
|
||||
import { authInterceptor } from './auth/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideAppInitializer(() => inject(KeycloakService).init()),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { from, switchMap } from 'rxjs';
|
||||
import { KeycloakService } from './keycloak.service';
|
||||
import { API_BASE_URL } from '../issues/issues-api.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.startsWith(API_BASE_URL)) {
|
||||
return next(req);
|
||||
}
|
||||
const keycloak = inject(KeycloakService);
|
||||
return from(keycloak.getToken()).pipe(
|
||||
switchMap((token) => {
|
||||
if (!token) return next(req);
|
||||
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -38,4 +38,13 @@ export class KeycloakService {
|
||||
isLoggedIn(): boolean {
|
||||
return this.keycloak.authenticated ?? false;
|
||||
}
|
||||
|
||||
async getToken(): Promise<string | undefined> {
|
||||
try {
|
||||
await this.keycloak.updateToken(30);
|
||||
return this.keycloak.token;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +39,14 @@ export class IssueComments {
|
||||
});
|
||||
}
|
||||
|
||||
protected addComment(): void {
|
||||
protected async addComment(): Promise<void> {
|
||||
const text = this.newCommentText.trim();
|
||||
if (!text) return;
|
||||
const issue = this.issuesStore.getById(this.issueId());
|
||||
if (!issue) return;
|
||||
const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1;
|
||||
const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null };
|
||||
this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
|
||||
await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
|
||||
this.newCommentText = '';
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class IssueComments {
|
||||
this.editingCommentText = comment.text;
|
||||
}
|
||||
|
||||
protected saveEditComment(): void {
|
||||
protected async saveEditComment(): Promise<void> {
|
||||
const text = this.editingCommentText.trim();
|
||||
if (!text || this.editingCommentId === null) return;
|
||||
const issue = this.issuesStore.getById(this.issueId());
|
||||
@@ -63,7 +63,7 @@ export class IssueComments {
|
||||
const updatedComments = issue.comments.map((c) =>
|
||||
c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c,
|
||||
);
|
||||
this.issuesStore.upsert({ ...issue, comments: updatedComments });
|
||||
await this.issuesStore.upsert({ ...issue, comments: updatedComments });
|
||||
this.editingCommentId = null;
|
||||
this.editingCommentText = '';
|
||||
}
|
||||
@@ -73,9 +73,9 @@ export class IssueComments {
|
||||
this.editingCommentText = '';
|
||||
}
|
||||
|
||||
protected deleteComment(id: number): void {
|
||||
protected async deleteComment(id: number): Promise<void> {
|
||||
const issue = this.issuesStore.getById(this.issueId());
|
||||
if (!issue) return;
|
||||
this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) });
|
||||
await this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ export class IssueDetail {
|
||||
protected moreMenuOpen = false;
|
||||
|
||||
constructor() {
|
||||
const idParam = this.route.snapshot.paramMap.get('id');
|
||||
const safeId = Number(idParam ?? 0);
|
||||
|
||||
this.issuesStore.load().then(() => {
|
||||
if (safeId) {
|
||||
const found = this.issuesStore.getById(safeId);
|
||||
if (found) this.issue = { ...found };
|
||||
}
|
||||
});
|
||||
|
||||
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const id = Number(params.get('id'));
|
||||
if (!id || isNaN(id)) return;
|
||||
@@ -38,6 +48,7 @@ export class IssueDetail {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected showAddDependency = false;
|
||||
protected selectedCandidateId: number | null = null;
|
||||
protected editingDescription = false;
|
||||
@@ -86,18 +97,18 @@ export class IssueDetail {
|
||||
this.selectedCandidateId = null;
|
||||
}
|
||||
|
||||
protected confirmAddDependency(): void {
|
||||
protected async confirmAddDependency(): Promise<void> {
|
||||
if (this.selectedCandidateId !== null) {
|
||||
this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId];
|
||||
this.saveIssue();
|
||||
await this.saveIssue();
|
||||
}
|
||||
this.showAddDependency = false;
|
||||
this.selectedCandidateId = null;
|
||||
}
|
||||
|
||||
protected removeDependency(id: number): void {
|
||||
protected async removeDependency(id: number): Promise<void> {
|
||||
this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id);
|
||||
this.saveIssue();
|
||||
await this.saveIssue();
|
||||
}
|
||||
|
||||
protected get estimatedTimeValue(): number | null {
|
||||
@@ -146,11 +157,11 @@ export class IssueDetail {
|
||||
this.newIssueName = '';
|
||||
}
|
||||
|
||||
protected confirmCreateInEpic(): void {
|
||||
protected async confirmCreateInEpic(): Promise<void> {
|
||||
const name = this.newIssueName.trim();
|
||||
if (!name) return;
|
||||
this.issuesStore.upsert({
|
||||
id: this.issuesStore.getNextId(),
|
||||
await this.issuesStore.upsert({
|
||||
id: 0,
|
||||
type: 'Story',
|
||||
assignee: '',
|
||||
epic: this.issue.name,
|
||||
@@ -178,11 +189,11 @@ export class IssueDetail {
|
||||
this.selectedEpicCandidateId = null;
|
||||
}
|
||||
|
||||
protected confirmAddToEpic(): void {
|
||||
protected async confirmAddToEpic(): Promise<void> {
|
||||
if (this.selectedEpicCandidateId !== null) {
|
||||
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
|
||||
if (target) {
|
||||
this.issuesStore.upsert({ ...target, epic: this.issue.name });
|
||||
await this.issuesStore.upsert({ ...target, epic: this.issue.name });
|
||||
}
|
||||
}
|
||||
this.showAddToEpic = false;
|
||||
@@ -229,12 +240,13 @@ export class IssueDetail {
|
||||
}
|
||||
}
|
||||
|
||||
protected saveIssue(explicit = false): void {
|
||||
protected async saveIssue(explicit = false): Promise<void> {
|
||||
if (this.isNewIssueRoute && !explicit) return;
|
||||
if (!this.issue.name.trim()) return;
|
||||
this.issuesStore.upsert(this.issue);
|
||||
const saved = await this.issuesStore.upsert(this.issue);
|
||||
this.issue = { ...saved };
|
||||
if (this.isNewIssueRoute) {
|
||||
this.router.navigate(['/issues', this.issue.id]);
|
||||
this.router.navigate(['/issues', saved.id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,14 +254,15 @@ export class IssueDetail {
|
||||
this.router.navigate(['/issues']);
|
||||
}
|
||||
|
||||
protected deleteIssue(): void {
|
||||
this.issuesStore.deleteById(this.issue.id);
|
||||
protected async deleteIssue(): Promise<void> {
|
||||
await this.issuesStore.deleteById(this.issue.id);
|
||||
this.router.navigate(['/issues']);
|
||||
}
|
||||
|
||||
protected updateStatus(status: IssueEntity['status']): void {
|
||||
protected async updateStatus(status: IssueEntity['status']): Promise<void> {
|
||||
this.issue.status = status;
|
||||
this.issuesStore.upsert(this.issue);
|
||||
const saved = await this.issuesStore.upsert(this.issue);
|
||||
this.issue = { ...saved };
|
||||
}
|
||||
|
||||
protected toggleMoreMenu(): void {
|
||||
@@ -262,15 +275,11 @@ export class IssueDetail {
|
||||
|
||||
private buildIssue(): IssueEntity {
|
||||
const idParam = this.route.snapshot.paramMap.get('id');
|
||||
const draftId = this.route.snapshot.queryParamMap.get('draftId');
|
||||
const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||
|
||||
const resolvedId = Number(idParam ?? draftId ?? 0);
|
||||
const safeId = Number.isNaN(resolvedId) ? 0 : resolvedId;
|
||||
|
||||
if (isNewIssueRoute) {
|
||||
return {
|
||||
id: safeId,
|
||||
id: 0,
|
||||
type: 'Story',
|
||||
assignee: '',
|
||||
epic: '',
|
||||
@@ -286,6 +295,7 @@ export class IssueDetail {
|
||||
};
|
||||
}
|
||||
|
||||
const safeId = Number(idParam ?? 0);
|
||||
const existingIssue = this.issuesStore.getById(safeId);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { IssueEntity } from './issues.store';
|
||||
|
||||
export const API_BASE_URL = 'http://localhost:8080';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IssuesApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getAll(): Observable<IssueEntity[]> {
|
||||
return this.http.get<IssueEntity[]>(`${API_BASE_URL}/issues`);
|
||||
}
|
||||
|
||||
create(issue: Omit<IssueEntity, 'id'>): Observable<IssueEntity> {
|
||||
return this.http.post<IssueEntity>(`${API_BASE_URL}/issues`, issue);
|
||||
}
|
||||
|
||||
update(id: number, issue: IssueEntity): Observable<IssueEntity> {
|
||||
return this.http.put<IssueEntity>(`${API_BASE_URL}/issues/${id}`, issue);
|
||||
}
|
||||
|
||||
remove(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${API_BASE_URL}/issues/${id}`);
|
||||
}
|
||||
}
|
||||
+44
-119
@@ -1,6 +1,6 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
const ISSUES_STORAGE_KEY = 'bonsai.issues';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { IssuesApiService } from './issues-api.service';
|
||||
|
||||
export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress';
|
||||
export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute';
|
||||
@@ -29,108 +29,60 @@ export type IssueEntity = {
|
||||
progress: number;
|
||||
};
|
||||
|
||||
const DEFAULT_ISSUES: IssueEntity[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Bug',
|
||||
assignee: 'Marie',
|
||||
epic: 'EPIC-UI',
|
||||
name: 'Bug affichage menu mobile',
|
||||
dueDate: '2026-06-10',
|
||||
description: 'Corriger le comportement du menu sur petits ecrans.',
|
||||
estimatedTime: 8,
|
||||
dependsOnIds: [],
|
||||
comments: [],
|
||||
priority: 'Haute',
|
||||
status: 'in-progress',
|
||||
progress: 35,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'Study',
|
||||
assignee: 'Nabil',
|
||||
epic: 'EPIC-FORM',
|
||||
name: 'Erreur validation formulaire projet',
|
||||
dueDate: '2026-06-12',
|
||||
description: 'Fiabiliser les regles de validation du formulaire projet.',
|
||||
estimatedTime: 16,
|
||||
dependsOnIds: [],
|
||||
comments: [],
|
||||
priority: 'Moyenne',
|
||||
status: 'todo',
|
||||
progress: 20,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'Story',
|
||||
assignee: 'Sonia',
|
||||
epic: 'EPIC-CONTENT',
|
||||
name: 'Mise a jour message de bienvenue',
|
||||
dueDate: '2026-06-18',
|
||||
description: 'Mettre a jour le wording d accueil selon la charte produit.',
|
||||
estimatedTime: 4,
|
||||
dependsOnIds: [],
|
||||
comments: [],
|
||||
priority: 'Basse',
|
||||
status: 'done',
|
||||
progress: 100,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IssuesStore {
|
||||
private readonly data = signal<IssueEntity[]>(DEFAULT_ISSUES);
|
||||
|
||||
constructor() {
|
||||
const cachedIssues = this.readFromStorage();
|
||||
if (cachedIssues) {
|
||||
this.data.set(cachedIssues.map((issue) => this.normalizeIssue(issue)));
|
||||
}
|
||||
}
|
||||
private readonly api = inject(IssuesApiService);
|
||||
private readonly data = signal<IssueEntity[]>([]);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly loaded = signal(false);
|
||||
readonly issues = this.data.asReadonly();
|
||||
|
||||
getById(id: number): IssueEntity | undefined {
|
||||
return this.data().find((issue) => issue.id === id);
|
||||
return this.data().find((i) => i.id === id);
|
||||
}
|
||||
|
||||
getNextId(): number {
|
||||
const ids = this.data().map((issue) => issue.id);
|
||||
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded()) return;
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const issues = await firstValueFrom(this.api.getAll());
|
||||
this.data.set(issues.map((i) => this.normalizeIssue(i)));
|
||||
this.loaded.set(true);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
upsert(issue: IssueEntity): void {
|
||||
const normalizedIssue = this.normalizeIssue(issue);
|
||||
|
||||
this.data.update((issues) => {
|
||||
const existingIndex = issues.findIndex((current) => current.id === issue.id);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
const created = [...issues, normalizedIssue];
|
||||
this.persistToStorage(created);
|
||||
async upsert(issue: IssueEntity): Promise<IssueEntity> {
|
||||
const normalized = this.normalizeIssue(issue);
|
||||
if (!normalized.id) {
|
||||
const { id: _id, ...body } = normalized;
|
||||
const created = this.normalizeIssue(await firstValueFrom(this.api.create(body)));
|
||||
this.data.update((issues) => [...issues, created]);
|
||||
return created;
|
||||
}
|
||||
|
||||
const updated = [...issues];
|
||||
updated[existingIndex] = normalizedIssue;
|
||||
this.persistToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
deleteById(id: number): void {
|
||||
} else {
|
||||
const updated = this.normalizeIssue(
|
||||
await firstValueFrom(this.api.update(normalized.id, normalized)),
|
||||
);
|
||||
this.data.update((issues) => {
|
||||
const updated = issues
|
||||
.filter((issue) => issue.id !== id)
|
||||
.map((issue) => ({
|
||||
...issue,
|
||||
dependsOnIds: issue.dependsOnIds.filter((dependencyId) => dependencyId !== id),
|
||||
}));
|
||||
|
||||
this.persistToStorage(updated);
|
||||
return updated;
|
||||
const idx = issues.findIndex((i) => i.id === normalized.id);
|
||||
if (idx === -1) return issues;
|
||||
const copy = [...issues];
|
||||
copy[idx] = updated;
|
||||
return copy;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await firstValueFrom(this.api.remove(id));
|
||||
this.data.update((issues) =>
|
||||
issues
|
||||
.filter((i) => i.id !== id)
|
||||
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeIssue(
|
||||
@@ -149,31 +101,4 @@ export class IssuesStore {
|
||||
comments: Array.isArray(issue.comments) ? issue.comments : [],
|
||||
} as IssueEntity;
|
||||
}
|
||||
|
||||
private readFromStorage(): IssueEntity[] | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawIssues = window.localStorage.getItem(ISSUES_STORAGE_KEY);
|
||||
if (!rawIssues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawIssues);
|
||||
return Array.isArray(parsed) ? (parsed as IssueEntity[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private persistToStorage(issues: IssueEntity[]): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(ISSUES_STORAGE_KEY, JSON.stringify(issues));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ export class Issues {
|
||||
private readonly router = inject(Router);
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
|
||||
constructor() {
|
||||
this.issuesStore.load();
|
||||
}
|
||||
|
||||
protected readonly issues = this.issuesStore.issues;
|
||||
protected selectedType: IssueEntity['type'] | null = null;
|
||||
|
||||
@@ -29,10 +33,7 @@ export class Issues {
|
||||
}
|
||||
|
||||
protected createIssue(): void {
|
||||
const nextId = this.issuesStore.getNextId();
|
||||
this.router.navigate(['/issues/new'], {
|
||||
queryParams: { draftId: nextId, mode: 'edit' },
|
||||
});
|
||||
this.router.navigate(['/issues/new']);
|
||||
}
|
||||
|
||||
protected openIssue(issueId: number): void {
|
||||
|
||||
Reference in New Issue
Block a user