Add init link.
This commit is contained in:
@@ -24,11 +24,15 @@ import { OfferingEntryMobilePageComponent } from './features/giving/pages/offeri
|
||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
|
||||
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
|
||||
import { AcceptInvitationComponent } from './features/accept-invitation/accept-invitation.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
// Public routes
|
||||
{ path: 'login', component: LoginPage },
|
||||
|
||||
// Public first-login page — member sets their own password from a secret invitation link.
|
||||
{ path: 'accept-invitation', component: AcceptInvitationComponent },
|
||||
|
||||
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
||||
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { LabelModule } from '@progress/kendo-angular-label';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||
import { AuthService } from '../../shared/services/auth.service';
|
||||
import {
|
||||
passwordStrengthValidator,
|
||||
passwordMatchValidator,
|
||||
} from '../account/validators/password.validators';
|
||||
|
||||
type Step = 'loading' | 'invalid' | 'form';
|
||||
|
||||
@Component({
|
||||
selector: 'app-accept-invitation',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, ReactiveFormsModule,
|
||||
InputsModule, LabelModule, ButtonsModule, IndicatorsModule,
|
||||
],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
|
||||
<h1 class="text-xl font-semibold mb-1">River Of Life Christian Church</h1>
|
||||
|
||||
<!-- Validating the link -->
|
||||
<ng-container *ngIf="step === 'loading'">
|
||||
<div class="text-center py-6">
|
||||
<kendo-loader></kendo-loader>
|
||||
<p class="mt-2 text-gray-600">Checking your invitation…</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Invalid / expired link -->
|
||||
<ng-container *ngIf="step === 'invalid'">
|
||||
<p class="text-base font-medium mb-2">This invitation can't be used</p>
|
||||
<p class="text-gray-600 mb-4">{{ invalidMessage }}</p>
|
||||
<button kendoButton themeColor="primary" (click)="goToLogin()">Go to sign in</button>
|
||||
</ng-container>
|
||||
|
||||
<!-- Set password form -->
|
||||
<ng-container *ngIf="step === 'form'">
|
||||
<p class="text-gray-600 mb-4">
|
||||
Welcome<span *ngIf="memberName">, <strong>{{ memberName }}</strong></span>. Set a password to
|
||||
finish creating your account and sign in.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
|
||||
<div class="grid grid-cols-1 gap-y-3">
|
||||
|
||||
<kendo-formfield>
|
||||
<kendo-label text="New Password *"></kendo-label>
|
||||
<kendo-textbox formControlName="newPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
|
||||
Must be at least 8 characters with an uppercase letter, a lowercase letter,
|
||||
a digit, and a special character.
|
||||
</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
|
||||
<kendo-formfield>
|
||||
<kendo-label text="Confirm Password *"></kendo-label>
|
||||
<kendo-textbox formControlName="confirmPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
|
||||
Passwords do not match.
|
||||
</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
|
||||
<p *ngIf="errorMessage" class="k-color-error">{{ errorMessage }}</p>
|
||||
|
||||
<div class="mt-2">
|
||||
<button kendoButton themeColor="primary" type="submit" [disabled]="form.invalid || submitting">
|
||||
<span *ngIf="submitting">…</span>
|
||||
Set password & sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AcceptInvitationComponent implements OnInit {
|
||||
step: Step = 'loading';
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
memberName: string | null = null;
|
||||
invalidMessage = 'This invitation link is invalid or has already been used.';
|
||||
errorMessage = '';
|
||||
|
||||
private token = '';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private auth: AuthService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
) {
|
||||
this.form = this.fb.group(
|
||||
{
|
||||
newPassword: ['', [Validators.required, passwordStrengthValidator()]],
|
||||
confirmPassword: ['', [Validators.required]],
|
||||
},
|
||||
{ validators: passwordMatchValidator() },
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
|
||||
if (!this.token) {
|
||||
this.step = 'invalid';
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.validateInvitation(this.token).subscribe({
|
||||
next: (result) => {
|
||||
if (result.valid) {
|
||||
this.memberName = result.memberName ?? null;
|
||||
this.step = 'form';
|
||||
} else {
|
||||
this.invalidMessage = result.expired
|
||||
? 'This invitation link has expired. Please ask for a new one.'
|
||||
: 'This invitation link is invalid or has already been used.';
|
||||
this.step = 'invalid';
|
||||
}
|
||||
},
|
||||
error: () => { this.step = 'invalid'; },
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.auth.acceptInvitation(this.token, this.form.value.newPassword).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/user-portal/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = err.error?.message ?? 'Could not set your password. The link may have expired.';
|
||||
this.submitting = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goToLogin(): void {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<kendo-dialog title="Invitation Link" (close)="onClose()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
|
||||
<!-- Ask for an email when the member has none on file -->
|
||||
<ng-container *ngIf="step === 'needEmail'">
|
||||
<p class="k-mb-4">
|
||||
Create a first-login invitation for <strong>{{ memberName }}</strong>.
|
||||
This member has no email on file — enter one to use as their login.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="emailForm" (ngSubmit)="generate()" class="k-form k-form-vertical">
|
||||
<kendo-formfield>
|
||||
<kendo-label text="Login Email *"></kendo-label>
|
||||
<kendo-textbox formControlName="email"></kendo-textbox>
|
||||
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['required']">Email is required.</kendo-formerror>
|
||||
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['email']">Invalid email address.</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
</form>
|
||||
|
||||
<p *ngIf="errorMessage" class="k-color-error k-mt-3">{{ errorMessage }}</p>
|
||||
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="onClose()">Cancel</button>
|
||||
<button kendoButton themeColor="primary" (click)="generate()">Create Link</button>
|
||||
</kendo-dialog-actions>
|
||||
</ng-container>
|
||||
|
||||
<!-- Generating spinner -->
|
||||
<ng-container *ngIf="step === 'generating'">
|
||||
<div class="k-text-center k-p-4">
|
||||
<kendo-loader></kendo-loader>
|
||||
<p class="k-mt-2">Creating invitation link…</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Ready — show link to copy / email -->
|
||||
<ng-container *ngIf="step === 'ready'">
|
||||
<p class="k-mb-3">
|
||||
Send this link to <strong>{{ memberName }}</strong>. They'll set their own password and sign in.
|
||||
</p>
|
||||
|
||||
<div class="k-d-flex k-gap-2 k-align-items-center k-mb-2">
|
||||
<kendo-textbox [value]="link" [readonly]="true" style="flex: 1"></kendo-textbox>
|
||||
<button kendoButton (click)="copyLink()">{{ copied ? 'Copied!' : 'Copy' }}</button>
|
||||
</div>
|
||||
|
||||
<p class="k-font-size-sm k-mb-3">
|
||||
Single use — expires {{ expiresAt | date:'medium' }}.
|
||||
</p>
|
||||
|
||||
<button kendoButton themeColor="info" (click)="sendEmail()" [disabled]="isSending">
|
||||
<span *ngIf="isSending">…</span>
|
||||
Send via email
|
||||
</button>
|
||||
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton themeColor="primary" (click)="onClose()">Done</button>
|
||||
</kendo-dialog-actions>
|
||||
</ng-container>
|
||||
|
||||
</kendo-dialog>
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { LabelModule } from '@progress/kendo-angular-label';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||
import { MemberListItemDto, memberDisplayName } from '../../models/member.model';
|
||||
import { InvitationApiService } from '../../services/invitation-api.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
|
||||
type Step = 'needEmail' | 'generating' | 'ready';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invitation-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, ReactiveFormsModule, DialogsModule, InputsModule,
|
||||
LabelModule, ButtonsModule, IndicatorsModule,
|
||||
],
|
||||
templateUrl: './invitation-dialog.component.html',
|
||||
})
|
||||
export class InvitationDialogComponent implements OnInit {
|
||||
@Input({ required: true }) member!: MemberListItemDto;
|
||||
@Output() cancelled = new EventEmitter<void>();
|
||||
|
||||
step: Step = 'generating';
|
||||
emailForm!: FormGroup;
|
||||
|
||||
link = '';
|
||||
expiresAt: string | null = null;
|
||||
copied = false;
|
||||
isSending = false;
|
||||
errorMessage = '';
|
||||
|
||||
get memberName(): string { return memberDisplayName(this.member); }
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private invitationApi: InvitationApiService,
|
||||
private toast: ToastService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.emailForm = this.fb.group({
|
||||
email: [this.member.email ?? '', [Validators.required, Validators.email]],
|
||||
});
|
||||
|
||||
// Auto-generate when the member already has an email; otherwise ask for one first.
|
||||
if (this.member.email) {
|
||||
this.generate();
|
||||
} else {
|
||||
this.step = 'needEmail';
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate (or re-issue) the link. Uses the form email when the member has none on file. */
|
||||
generate(): void {
|
||||
if (this.step === 'needEmail') {
|
||||
if (this.emailForm.invalid) { this.emailForm.markAllAsTouched(); return; }
|
||||
}
|
||||
|
||||
const email = this.member.email ? undefined : this.emailForm.value.email;
|
||||
this.step = 'generating';
|
||||
this.errorMessage = '';
|
||||
|
||||
this.invitationApi.create(this.member.id, email).subscribe({
|
||||
next: (result) => {
|
||||
this.link = `${window.location.origin}/accept-invitation?token=${result.token}`;
|
||||
this.expiresAt = result.expiresAt;
|
||||
this.step = 'ready';
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = err.error?.message ?? 'Failed to create the invitation link.';
|
||||
// Fall back to the email step so the admin can supply/correct an address.
|
||||
this.step = 'needEmail';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
copyLink(): void {
|
||||
navigator.clipboard.writeText(this.link).then(() => {
|
||||
this.copied = true;
|
||||
setTimeout(() => (this.copied = false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
sendEmail(): void {
|
||||
this.isSending = true;
|
||||
this.invitationApi.sendEmail(this.member.id, this.link).subscribe({
|
||||
next: () => {
|
||||
this.toast.success(`Invitation emailed to ${this.memberName}.`);
|
||||
this.isSending = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.toast.error(err.error?.message ?? 'Failed to send the email.');
|
||||
this.isSending = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
}
|
||||
@@ -63,13 +63,15 @@
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="210">
|
||||
<kendo-grid-column title="Actions" [width]="290">
|
||||
<ng-template kendoGridCellTemplate let-row>
|
||||
<div class="k-d-flex k-gap-2">
|
||||
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
||||
<button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button>
|
||||
<button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info"
|
||||
(click)="openCreateUserDialog(row)">+ Account</button>
|
||||
<button *appHasPermission="['Users', 'write']" kendoButton size="small" themeColor="warning"
|
||||
(click)="openInviteDialog(row)">Invite</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
@@ -92,3 +94,10 @@
|
||||
(created)="onUserCreated()"
|
||||
(cancelled)="closeCreateUserDialog()">
|
||||
</app-create-user-dialog>
|
||||
|
||||
<!-- Invitation Link Dialog -->
|
||||
<app-invitation-dialog
|
||||
*ngIf="showInviteDialog && selectedMemberForInvite"
|
||||
[member]="selectedMemberForInvite"
|
||||
(cancelled)="closeInviteDialog()">
|
||||
</app-invitation-dialog>
|
||||
|
||||
@@ -9,12 +9,14 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { MemberApiService } from '../../services/member-api.service';
|
||||
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
|
||||
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
|
||||
import { InvitationDialogComponent } from '../../components/invitation-dialog/invitation-dialog.component';
|
||||
import {
|
||||
MemberListItemDto, MemberDto, CreateMemberRequest,
|
||||
PagedResult, memberDisplayName
|
||||
} from '../../models/member.model';
|
||||
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-members-page',
|
||||
@@ -22,7 +24,8 @@ import { PageHeaderActionsDirective } from '../../../../shared/directives/page-h
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, InputsModule,
|
||||
ButtonsModule, IndicatorsModule, DropDownsModule,
|
||||
MemberFormDialogComponent, CreateUserDialogComponent, PageHeaderActionsDirective,
|
||||
MemberFormDialogComponent, CreateUserDialogComponent, InvitationDialogComponent,
|
||||
PageHeaderActionsDirective, HasPermissionDirective,
|
||||
],
|
||||
templateUrl: './members-page.component.html',
|
||||
styleUrls: ['./members-page.component.scss'],
|
||||
@@ -46,8 +49,10 @@ export class MembersPageComponent implements OnInit {
|
||||
// Dialogs
|
||||
showMemberDialog = false;
|
||||
showCreateUserDialog = false;
|
||||
showInviteDialog = false;
|
||||
editingMember: MemberDto | null = null;
|
||||
selectedMemberForUser: MemberListItemDto | null = null;
|
||||
selectedMemberForInvite: MemberListItemDto | null = null;
|
||||
|
||||
readonly memberDisplayName = memberDisplayName;
|
||||
|
||||
@@ -139,4 +144,18 @@ export class MembersPageComponent implements OnInit {
|
||||
this.closeCreateUserDialog();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
// ── Invitation Link ─────────────────────────────────────────────────────────
|
||||
|
||||
openInviteDialog(member: MemberListItemDto): void {
|
||||
this.selectedMemberForInvite = member;
|
||||
this.showInviteDialog = true;
|
||||
}
|
||||
|
||||
closeInviteDialog(): void {
|
||||
this.showInviteDialog = false;
|
||||
this.selectedMemberForInvite = null;
|
||||
// An invitation may have just created an account, so refresh the grid.
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
|
||||
export interface CreateInvitationResult {
|
||||
/** Raw, single-use token — returned once; build the link from it client-side. */
|
||||
token: string;
|
||||
/** ISO timestamp when the link stops working. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InvitationApiService {
|
||||
private readonly endpoint: string;
|
||||
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.endpoint = apiConfig.getApiUrl('invitations');
|
||||
}
|
||||
|
||||
/** Generate (or re-issue) a first-login invitation link for a member. */
|
||||
create(memberId: number, email?: string): Observable<CreateInvitationResult> {
|
||||
return this.http.post<CreateInvitationResult>(this.endpoint, { memberId, email });
|
||||
}
|
||||
|
||||
/** E-mail an already-generated link to the member. */
|
||||
sendEmail(memberId: number, link: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.endpoint}/send`, { memberId, link });
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ export interface LoginResult {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Matches the C# ValidateInvitationResult DTO. */
|
||||
export interface ValidateInvitationResult {
|
||||
valid: boolean;
|
||||
expired: boolean;
|
||||
memberName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface TokenVerificationResult {
|
||||
isValid: boolean;
|
||||
/** Constructed from JWT claims when using secret-link login. */
|
||||
@@ -177,6 +185,36 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an invitation token is still usable (anonymous). Used by the
|
||||
* public "set your password" page to decide what to show before the member types.
|
||||
*/
|
||||
validateInvitation(token: string): Observable<ValidateInvitationResult> {
|
||||
return this.http.get<ValidateInvitationResult>(
|
||||
`${this.apiConfig.authUrl}/invitation/validate`,
|
||||
{ params: { token } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes an invitation: sets the password and logs the member in. On success the
|
||||
* server returns a normal login payload, so we store the access token + user (and the
|
||||
* refresh cookie is set server-side) exactly like login(). Errors propagate to the caller.
|
||||
*/
|
||||
acceptInvitation(token: string, newPassword: string): Observable<UserInfo> {
|
||||
return this.http.post<ApiLoginResponse>(
|
||||
`${this.apiConfig.authUrl}/accept-invitation`,
|
||||
{ token, newPassword },
|
||||
{ withCredentials: true }
|
||||
).pipe(
|
||||
tap(response => {
|
||||
this.accessToken$.next(response.accessToken);
|
||||
this.currentUser$.next(response.user);
|
||||
}),
|
||||
map(response => response.user)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears in-memory auth state immediately, then fires a fire-and-forget
|
||||
* POST to revoke the server-side refresh token cookie.
|
||||
|
||||
Reference in New Issue
Block a user