Add user management and registration features with invite-based onboarding

- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation.
- Add `+page.svelte` for user registration using invite tokens.
-
main
Pau Costa Ferrer 2026-03-28 16:11:10 +01:00
parent 30d3ab0686
commit b3d7e4195f
12 changed files with 607 additions and 10 deletions

View File

@ -4,13 +4,10 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing."> <list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/app.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/auth/auth.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/auth/auth.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -132,7 +129,7 @@
<updated>1774643312599</updated> <updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" /> <workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" /> <workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="3866000" /> <workItem from="1774706361502" duration="4182000" />
</task> </task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services."> <task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" /> <option name="closed" value="true" />
@ -190,7 +187,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774709613724</updated> <updated>1774709613724</updated>
</task> </task>
<option name="localTasksCounter" value="8" /> <task id="LOCAL-00008" summary="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data.">
<option name="closed" value="true" />
<created>1774710366017</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1774710366017</updated>
</task>
<option name="localTasksCounter" value="9" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -204,6 +209,7 @@
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." /> <MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." />
<MESSAGE value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." /> <MESSAGE value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
<MESSAGE value="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing." /> <MESSAGE value="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing." />
<option name="LAST_COMMIT_MESSAGE" value="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing." /> <MESSAGE value="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data." />
<option name="LAST_COMMIT_MESSAGE" value="Add admin reporting dashboard with utilization statistics and downloadable reports&#10;&#10;- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.&#10;- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).&#10;- Create `ReportsService` and `ReportsModule` for backend logic.&#10;- Introduce `xlsx` package for generating Excel reports.&#10;- Update layout to include &quot;Reports&quot; link for admin users.&#10;- Extend Prisma querying for detailed building, space type, and employee role data." />
</component> </component>
</project> </project>

View File

@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module';
import { ReservationsModule } from './reservations/reservations.module'; import { ReservationsModule } from './reservations/reservations.module';
import { EventsModule } from './events/events.module'; import { EventsModule } from './events/events.module';
import { ReportsModule } from './reports/reports.module'; import { ReportsModule } from './reports/reports.module';
import { UsersModule } from './users/users.module';
import { LoggerMiddleware } from './common/middleware/logger.middleware'; import { LoggerMiddleware } from './common/middleware/logger.middleware';
@Module({ @Module({
@ -19,6 +20,7 @@ import { LoggerMiddleware } from './common/middleware/logger.middleware';
ReservationsModule, ReservationsModule,
EventsModule, EventsModule,
ReportsModule, ReportsModule,
UsersModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -1,6 +1,7 @@
import { Controller, Post, Body } from '@nestjs/common'; import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -10,4 +11,9 @@ export class AuthController {
async login(@Body() loginDto: LoginDto) { async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto); return this.authService.login(loginDto);
} }
@Post('register')
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
} }

View File

@ -1,8 +1,9 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -35,4 +36,39 @@ export class AuthService {
} }
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
async register(registerDto: RegisterDto) {
try {
const payload = this.jwtService.verify(registerDto.token);
if (payload.type !== 'INVITE') {
throw new BadRequestException('Invalid invite token');
}
const existing = await this.prisma.user.findUnique({
where: { email: registerDto.email },
});
if (existing) {
throw new BadRequestException('Email already registered');
}
const hashedPassword = await bcrypt.hash(registerDto.password, 10);
const user = await this.prisma.user.create({
data: {
email: registerDto.email,
password: hashedPassword,
role: payload.role,
organizationId: payload.organizationId,
},
});
return {
id: user.id,
email: user.email,
role: user.role,
};
} catch (e) {
if (e instanceof BadRequestException) throw e;
throw new BadRequestException('Invalid or expired invite token');
}
}
} }

View File

@ -0,0 +1,16 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(6)
password: string;
@IsString()
@IsNotEmpty()
token: string;
}

View File

@ -0,0 +1,17 @@
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { Role } from '@prisma/client';
export class UpdateUserDto {
@IsEmail()
@IsOptional()
email?: string;
@IsEnum(Role)
@IsOptional()
role?: Role;
@IsString()
@MinLength(6)
@IsOptional()
password?: string;
}

View File

@ -0,0 +1,34 @@
import { Controller, Get, Post, Patch, Body, Param, UseGuards, Req } from '@nestjs/common';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll(@Req() req) {
return this.usersService.findAll(req.user.organizationId);
}
@Get(':id')
findOne(@Param('id') id: string, @Req() req) {
return this.usersService.findOne(id, req.user.organizationId);
}
@Patch(':id')
update(@Param('id') id: string, @Req() req, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(id, req.user.organizationId, updateUserDto);
}
@Post('invite')
createInvite(@Req() req, @Body('role') role?: string) {
return this.usersService.createInvite(req.user.organizationId, role);
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET') || 'secret',
signOptions: { expiresIn: '7d' },
}),
}),
],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,72 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateUserDto } from './dto/update-user.dto';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class UsersService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async findAll(organizationId: string) {
return this.prisma.user.findMany({
where: { organizationId },
select: {
id: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
orderBy: { email: 'asc' },
});
}
async findOne(id: string, organizationId: string) {
const user = await this.prisma.user.findFirst({
where: { id, organizationId },
select: {
id: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async update(id: string, organizationId: string, updateUserDto: UpdateUserDto) {
const user = await this.findOne(id, organizationId);
const data: any = { ...updateUserDto };
if (data.password) {
data.password = await bcrypt.hash(data.password, 10);
}
return this.prisma.user.update({
where: { id: user.id },
data,
select: {
id: true,
email: true,
role: true,
updatedAt: true,
},
});
}
async createInvite(organizationId: string, role: string = 'EMPLOYEE') {
const payload = { organizationId, role, type: 'INVITE' };
const token = this.jwtService.sign(payload, { expiresIn: '7d' });
return { token };
}
}

View File

@ -14,6 +14,7 @@
<div class="space-x-4 flex items-center"> <div class="space-x-4 flex items-center">
{#if $token} {#if $token}
{#if $user?.role === 'ADMIN'} {#if $user?.role === 'ADMIN'}
<a href="/admin/users" class="text-gray-600 hover:text-indigo-600 font-medium">Users</a>
<a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">Reports</a> <a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">Reports</a>
{/if} {/if}
<a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">Dashboard</a> <a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">Dashboard</a>

View File

@ -0,0 +1,226 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api/client';
import { token } from '$lib/stores/auth';
let users = [];
let loading = true;
let error = null;
let inviteToken = '';
let showInviteModal = false;
let inviteRole = 'EMPLOYEE';
let copied = false;
let showPasswordModal = false;
let selectedUser = null;
let newPassword = '';
async function fetchUsers() {
try {
users = await apiFetch<any[]>('/users');
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function updateUserRole(userId: string, newRole: string) {
try {
await apiFetch(`/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ role: newRole })
});
await fetchUsers();
} catch (e) {
alert('Error updating role: ' + e.message);
}
}
async function openPasswordModal(user) {
selectedUser = user;
newPassword = '';
showPasswordModal = true;
}
async function updatePassword() {
try {
await apiFetch(`/users/${selectedUser.id}`, {
method: 'PATCH',
body: JSON.stringify({ password: newPassword })
});
showPasswordModal = false;
alert('Password updated successfully');
} catch (e) {
alert('Error updating password: ' + e.message);
}
}
async function generateInvite() {
try {
const res = await apiFetch<any>('/users/invite', {
method: 'POST',
body: JSON.stringify({ role: inviteRole })
});
const baseUrl = window.location.origin;
inviteToken = `${baseUrl}/register?token=${res.token}`;
showInviteModal = true;
} catch (e) {
alert('Error generating invite: ' + e.message);
}
}
function copyInvite() {
navigator.clipboard.writeText(inviteToken);
copied = true;
setTimeout(() => copied = false, 2000);
}
onMount(fetchUsers);
</script>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">User Management</h1>
<div class="flex space-x-2">
<select bind:value={inviteRole} class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="EMPLOYEE">Employee</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="ADMIN">Admin</option>
</select>
<button
on:click={generateInvite}
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
>
Create Invite Link
</button>
</div>
</div>
{#if loading}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
{:else if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg border border-red-200">
{error}
</div>
{:else}
<div class="bg-white shadow-sm border border-gray-100 rounded-xl overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each users as user}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{user.email}</div>
<div class="text-xs text-gray-500">ID: {user.id.substring(0, 8)}...</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<select
value={user.role}
on:change={(e) => updateUserRole(user.id, e.target.value)}
class="text-sm rounded-md border-gray-200 focus:ring-indigo-500 focus:border-indigo-500 py-1"
>
<option value="EMPLOYEE">Employee</option>
<option value="SUPERVISOR">Supervisor</option>
<option value="ADMIN">Admin</option>
</select>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
on:click={() => openPasswordModal(user)}
class="text-indigo-600 hover:text-indigo-900 bg-indigo-50 px-3 py-1 rounded-md"
>
Change Password
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Invite Modal -->
{#if showInviteModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl p-6 max-w-lg w-full shadow-xl">
<h3 class="text-lg font-bold text-gray-900 mb-4">Invitation Link Generated</h3>
<p class="text-sm text-gray-500 mb-4">Anyone with this link can register as a <strong>{inviteRole}</strong> in your organization.</p>
<div class="flex items-center space-x-2 bg-gray-50 p-3 rounded-lg border border-gray-200 mb-6">
<input type="text" readonly value={inviteToken} class="bg-transparent border-none focus:ring-0 text-sm flex-1 truncate" />
<button
on:click={copyInvite}
class="bg-white border border-gray-300 px-3 py-1.5 rounded-md text-sm font-medium hover:bg-gray-50 flex items-center space-x-1"
>
{#if copied}
<span class="text-green-600">Copied!</span>
{:else}
<span>Copy</span>
{/if}
</button>
</div>
<div class="flex justify-end">
<button
on:click={() => showInviteModal = false}
class="bg-gray-900 text-white px-6 py-2 rounded-lg font-medium"
>
Done
</button>
</div>
</div>
</div>
{/if}
<!-- Password Modal -->
{#if showPasswordModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl p-6 max-w-md w-full shadow-xl">
<h3 class="text-lg font-bold text-gray-900 mb-2">Change Password</h3>
<p class="text-sm text-gray-500 mb-4">Set a new password for <strong>{selectedUser?.email}</strong></p>
<div class="space-y-4 mb-6">
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
<input
type="password"
id="new-password"
bind:value={newPassword}
placeholder="At least 6 characters"
class="w-full rounded-lg border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => showPasswordModal = false}
class="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-lg"
>
Cancel
</button>
<button
on:click={updatePassword}
disabled={newPassword.length < 6}
class="bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white px-6 py-2 rounded-lg font-medium"
>
Save Password
</button>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,159 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { apiFetch } from '$lib/api/client';
let email = '';
let password = '';
let confirmPassword = '';
let token = '';
let loading = false;
let error = null;
let success = false;
onMount(() => {
token = $page.url.searchParams.get('token');
if (!token) {
error = 'No invitation token found. Please use a valid invite link.';
}
});
async function handleRegister() {
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
loading = true;
error = null;
try {
await apiFetch('/auth/register', {
method: 'POST',
body: JSON.stringify({
email,
password,
token
})
});
success = true;
setTimeout(() => goto('/login'), 3000);
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
</script>
<div class="min-h-[80vh] flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="w-12 h-12 bg-indigo-600 rounded-xl flex items-center justify-center mx-auto mb-4">
<span class="text-white font-bold text-xl">H</span>
</div>
<h2 class="text-center text-3xl font-extrabold text-gray-900">
Join the platform
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Create your account using the invitation
</p>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 border border-gray-100">
{#if success}
<div class="rounded-md bg-green-50 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
Registration successful! Redirecting to login...
</p>
</div>
</div>
</div>
{:else}
{#if error}
<div class="rounded-md bg-red-50 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800">{error}</p>
</div>
</div>
</div>
{/if}
<form on:submit|preventDefault={handleRegister} class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
required
bind:value={email}
disabled={!token || loading}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<div class="mt-1">
<input
id="password"
name="password"
type="password"
required
bind:value={password}
disabled={!token || loading}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700">Confirm Password</label>
<div class="mt-1">
<input
id="confirm-password"
name="confirm-password"
type="password"
required
bind:value={confirmPassword}
disabled={!token || loading}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={!token || loading}
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{#if loading}
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
{:else}
Register
{/if}
</button>
</div>
</form>
{/if}
</div>
</div>
</div>