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
parent
30d3ab0686
commit
b3d7e4195f
|
|
@ -4,13 +4,10 @@
|
|||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add support for space resizing and enhanced layout management - Update `SpacesService` to handle width and height properties for spaces. - Introduce `UpdateSpaceDto` for improved validation in batch update operations. - Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing. - Modify Prisma schema to include `width` and `height` fields for spaces. - Enhance floorplan rendering to maintain proportional layout during editing and viewing.">
|
||||
<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" />
|
||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add admin reporting dashboard with utilization statistics and downloadable reports - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - Extend Prisma querying for detailed building, space type, and employee role data.">
|
||||
<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$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" 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$/apps/api/src/auth/auth.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
|
|
@ -132,7 +129,7 @@
|
|||
<updated>1774643312599</updated>
|
||||
<workItem from="1774643313611" duration="74000" />
|
||||
<workItem from="1774643422240" duration="5278000" />
|
||||
<workItem from="1774706361502" duration="3866000" />
|
||||
<workItem from="1774706361502" duration="4182000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
||||
<option name="closed" value="true" />
|
||||
|
|
@ -190,7 +187,15 @@
|
|||
<option name="project" value="LOCAL" />
|
||||
<updated>1774709613724</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="8" />
|
||||
<task id="LOCAL-00008" summary="Add admin reporting dashboard with utilization statistics and downloadable reports - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - 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 />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
|
@ -204,6 +209,7 @@
|
|||
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer - Create `+page.svelte` for building-level dashboard with floor selection and layout preview. - Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates. - Extend `SpacesService` and API endpoints for building floors and floor details retrieval. - Update dashboard UI with cleaner building and floor presentation. - Improve loading states and error handling across dashboard and floor pages." />
|
||||
<MESSAGE value="Add booking modal and enhance reservation handling - Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations. - Add API endpoint to fetch reservations by space ID and date. - Update `SpacesService` with enhanced validation rules and conflict checks for reservations. - Extend WebSocket updates to include reservation data for real-time synchronization. - 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 - Update `SpacesService` to handle width and height properties for spaces. - Introduce `UpdateSpaceDto` for improved validation in batch update operations. - Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing. - Modify Prisma schema to include `width` and `height` fields for spaces. - 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 - Update `SpacesService` to handle width and height properties for spaces. - Introduce `UpdateSpaceDto` for improved validation in batch update operations. - Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing. - Modify Prisma schema to include `width` and `height` fields for spaces. - Enhance floorplan rendering to maintain proportional layout during editing and viewing." />
|
||||
<MESSAGE value="Add admin reporting dashboard with utilization statistics and downloadable reports - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - 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 - Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats. - Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS). - Create `ReportsService` and `ReportsModule` for backend logic. - Introduce `xlsx` package for generating Excel reports. - Update layout to include "Reports" link for admin users. - Extend Prisma querying for detailed building, space type, and employee role data." />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module';
|
|||
import { ReservationsModule } from './reservations/reservations.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { LoggerMiddleware } from './common/middleware/logger.middleware';
|
||||
|
||||
@Module({
|
||||
|
|
@ -19,6 +20,7 @@ import { LoggerMiddleware } from './common/middleware/logger.middleware';
|
|||
ReservationsModule,
|
||||
EventsModule,
|
||||
ReportsModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
|
|
@ -10,4 +11,9 @@ export class AuthController {
|
|||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
|
|
@ -35,4 +36,39 @@ export class AuthService {
|
|||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
<div class="space-x-4 flex items-center">
|
||||
{#if $token}
|
||||
{#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>
|
||||
{/if}
|
||||
<a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">Dashboard</a>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue