improvement/one-user-multiple-orgs #6

Merged
admin merged 2 commits from improvement/one-user-multiple-orgs into master 2026-04-29 21:59:40 +03:30
14 changed files with 317 additions and 65 deletions
Showing only changes of commit ca9e684ab4 - Show all commits

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users"
ADD COLUMN "trialUsedAt" TIMESTAMP(3);

View File

@@ -15,6 +15,7 @@ model User {
googleId String? @unique
facebookId String? @unique
name String
trialUsedAt DateTime?
memberships Membership[]
ownedOrganizations Organization[] @relation("OrganizationOwner")

View File

@@ -25,6 +25,7 @@ import {
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';
@@ -120,6 +121,14 @@ export class AuthController {
};
}
@Post('organizations')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create organization for current user' })
async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) {
return this.authService.createOrganization(req.user.id, dto);
}
// =========================
// PROFILE
// =========================

View File

@@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [
@@ -176,7 +177,7 @@ export class AuthService {
* @returns Created user info without password
*/
async register(registerDto: RegisterDto) {
const { email, password, name, organizationName, organizationType } = registerDto;
const { email, password, name, organizationName, organizationEmail, organizationType } = registerDto;
// 1. Check existing user
const existingUser = await this.prisma.user.findUnique({
@@ -184,7 +185,7 @@ export class AuthService {
});
if (existingUser) {
throw new ConflictException('User already exists');
throw new ConflictException('User already exists. Please login and create a new organization from your account.');
}
// 2. Hash password
@@ -198,16 +199,15 @@ export class AuthService {
email,
passwordHash: hashedPassword,
name,
trialUsedAt: new Date(),
},
});
// Create organization
const organization = await tx.organization.create({
data: {
name: registerDto.organizationName,
// REQUIRED FIELDS 👇
email: registerDto.email, // or separate org email if you have one
name: organizationName,
email: organizationEmail,
owner: {
connect: { id: user.id },
@@ -219,7 +219,7 @@ export class AuthService {
type: {
connect: {
name: registerDto.organizationType, // 'CLINIC' | 'LAB'
name: organizationType, // 'CLINIC' | 'LAB'
},
},
},
@@ -247,6 +247,66 @@ export class AuthService {
return this.login({ email, password } as any, validatedUser);
}
async createOrganization(userId: string, dto: CreateOrganizationDto) {
const owner = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, trialUsedAt: true },
});
if (!owner) {
throw new UnauthorizedException('User not found');
}
const planName = dto.planName?.trim() || 'Small';
const effectivePlanName = owner.trialUsedAt ? planName : 'trial';
const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({
data: {
name: dto.organizationName,
email: dto.organizationEmail,
owner: {
connect: { id: userId },
},
plan: {
connect: { name: effectivePlanName },
},
type: {
connect: { name: dto.organizationType },
},
},
});
await tx.membership.create({
data: {
userId,
organizationId: createdOrganization.id,
isOwner: true,
},
});
if (!owner.trialUsedAt) {
await tx.user.update({
where: { id: userId },
data: { trialUsedAt: new Date() },
});
}
return createdOrganization;
});
return {
success: true,
data: {
organization: {
id: organization.id,
name: organization.name,
email: organization.email,
},
},
};
}
/**
* Get user profile with all memberships and permissions
* @param userId - User ID from JWT token
@@ -617,6 +677,7 @@ export class AuthService {
organizationId,
},
include: {
user: true,
organization: {
include: {
type: true,
@@ -638,7 +699,7 @@ export class AuthService {
// 2. Build payload WITH org context
const payload = {
sub: userId,
email: membership.organization.email,
email: membership.user.email,
organizationId: membership.organizationId,
type: 'access',
};

View File

@@ -0,0 +1,16 @@
import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator';
export class CreateOrganizationDto {
@IsString()
organizationName: string;
@IsEmail()
organizationEmail: string;
@IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB';
@IsOptional()
@IsString()
planName?: string;
}

View File

@@ -14,6 +14,9 @@ export class RegisterDto {
@IsString()
organizationName: string;
@IsEmail()
organizationEmail: string;
@IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB';
}

View File

@@ -2,6 +2,7 @@
export interface JwtPayload {
sub: string; // user id
email: string;
organizationId?: string;
type?: 'access' | 'refresh';
}

View File

@@ -18,6 +18,7 @@ const registerSchema = z.object({
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
organizationName: z.string().min(2, 'Organization name must be at least 2 characters'),
organizationEmail: z.string().email('Please enter a valid organization email'),
organizationType: z.enum(['CLINIC', 'LAB'], {
message: 'Please select organization type',
}),
@@ -47,7 +48,7 @@ export default function RegisterPage() {
const handleNext = async () => {
const fieldsToValidate = step === 1
? ['name', 'email', 'password', 'confirmPassword']
: ['organizationName', 'organizationType'];
: ['organizationName', 'organizationEmail', 'organizationType'];
const isValid = await trigger(fieldsToValidate as any);
if (isValid) {
@@ -62,6 +63,7 @@ export default function RegisterPage() {
data.password,
data.name,
data.organizationName,
data.organizationEmail,
data.organizationType
);
// No need to redirect - auth context will handle it
@@ -182,6 +184,14 @@ export default function RegisterPage() {
error={errors.organizationName?.message}
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
{...register('organizationEmail')}
type="email"
placeholder="contact@sunshineclinic.com"
error={errors.organizationEmail?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type

View File

@@ -1,18 +1,17 @@
'use client';
import { useEffect } from 'react';
import { useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth';
import { Building2, Beaker } from 'lucide-react';
import { Building2, Beaker, Mail, Plus } from 'lucide-react';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
export default function SelectOrganizationPage() {
const { organizations, selectOrganization, isLoading } = useAuth();
// ✅ Auto-redirect if only one organization
useEffect(() => {
if (!isLoading && organizations.length === 1) {
selectOrganization(organizations[0].id);
}
}, [organizations, isLoading]);
const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [organizationName, setOrganizationName] = useState('');
const [organizationEmail, setOrganizationEmail] = useState('');
const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC');
const getIcon = (type: string) => {
return type === 'CLINIC'
@@ -20,60 +19,152 @@ export default function SelectOrganizationPage() {
: <Beaker className="h-8 w-8 icon-flat" />;
};
const handleCreateOrganization = async () => {
try {
clearError();
const createdId = await createOrganization(
organizationName.trim(),
organizationEmail.trim(),
organizationType,
);
setOrganizationName('');
setOrganizationEmail('');
setOrganizationType('CLINIC');
setIsCreateOpen(false);
await selectOrganization(createdId);
} catch {
// Error is already handled in auth context.
}
};
if (isLoading) {
return (
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading organizations...</p>
</div>
);
}
if (!organizations.length) {
return (
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">No organizations found.</p>
<p className="text-text-secondary">Loading...</p>
</div>
);
}
return (
<div className="min-h-screen app-web-bg flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-semibold text-text-primary">
Choose Organization
</h1>
<p className="text-text-secondary mt-2">
You have access to multiple organizations. Select one to continue.
</p>
<div className="min-h-screen app-web-bg p-4 sm:p-8">
<div className="max-w-3xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<p className="text-text-secondary mt-2">
Select an organization to continue, or create a new one.
</p>
</div>
<Button
type="button"
variant={isCreateOpen ? 'outline' : 'primary'}
onClick={() => {
clearError();
setIsCreateOpen(prev => !prev);
}}
>
<Plus className="h-4 w-4 mr-2 icon-flat" />
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
</div>
<div className="grid gap-4">
{organizations.map((org) => (
<button
key={org.id}
onClick={() => selectOrganization(org.id)}
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
>
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
{getIcon(org.type)}
{isCreateOpen && (
<div className="surface-card p-6 mb-6 space-y-4">
<Input
label="Organization name"
value={organizationName}
onChange={(event) => setOrganizationName(event.target.value)}
placeholder="Sunshine Dental Clinic"
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
value={organizationEmail}
onChange={(event) => setOrganizationEmail(event.target.value)}
placeholder="contact@sunshineclinic.com"
type="email"
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOrganizationType('CLINIC')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'CLINIC'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Clinic
</button>
<button
type="button"
onClick={() => setOrganizationType('LAB')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'LAB'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Lab
</button>
</div>
</div>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="primary"
onClick={handleCreateOrganization}
isLoading={isLoading}
disabled={!organizationName.trim() || !organizationEmail.trim()}
>
Create and Continue
</Button>
</div>
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
{!organizations.length ? (
<div className="surface-card p-8 text-center">
<p className="text-text-secondary">No organizations found. Create your first one to continue.</p>
</div>
) : (
<div className="grid gap-4">
{organizations.map((org) => (
<button
key={org.id}
onClick={() => selectOrganization(org.id)}
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
>
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
{getIcon(org.type)}
</div>
<div className="text-primary text-sm">
Continue
</div>
</button>
))}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
<div className="text-primary text-sm">
Continue
</div>
</button>
))}
</div>
)}
</div>
</div>
);

View File

@@ -10,7 +10,8 @@ import {
UserCog,
FlaskConical,
FileText,
CreditCard
CreditCard,
Building2
} from 'lucide-react';
const menu = [
@@ -21,6 +22,7 @@ const menu = [
{ name: 'Lab Management', path: '/lab', icon: FlaskConical },
{ name: 'Billing', path: '/billing', icon: CreditCard },
{ name: 'Reports', path: '/reports', icon: FileText },
{ name: 'Organizations', path: '/select-organization', icon: Building2 },
];
function Sidebar() {

View File

@@ -27,6 +27,17 @@ export const authApi = {
return response.data;
},
// Create organization for current user
createOrganization: async (data: {
organizationName: string;
organizationEmail: string;
organizationType: 'CLINIC' | 'LAB';
planName?: string;
}): Promise<any> => {
const response = await apiClient.post('/auth/organizations', data);
return response.data;
},
// Logout
logout: async (): Promise<void> => {
await apiClient.post('/auth/logout');

View File

@@ -17,11 +17,18 @@ interface AuthContextType {
password: string,
name: string,
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB'
) => Promise<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
selectOrganization: (orgId: string) => Promise<void>;
createOrganization: (
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB',
planName?: string,
) => Promise<string>;
clearError: () => void;
}
@@ -111,6 +118,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password: string,
name: string,
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB'
) => {
try {
@@ -122,6 +130,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password,
name,
organizationName,
organizationEmail,
organizationType,
});
@@ -221,6 +230,39 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [router]);
const createOrganization = useCallback(async (
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB',
planName?: string,
) => {
try {
setIsLoading(true);
setError(null);
const createResponse = await authApi.createOrganization({
organizationName,
organizationEmail,
organizationType,
planName,
});
const profileResponse = await authApi.getProfile();
if (profileResponse.success) {
const { user: userData, organizations: orgs } = normalizeProfilePayload(profileResponse.data);
setUser(userData);
setOrganizations(orgs);
}
return createResponse.data.organization.id as string;
} catch (err: any) {
setError(err.message || 'Failed to create organization');
throw err;
} finally {
setIsLoading(false);
}
}, [normalizeProfilePayload]);
const clearError = useCallback(() => setError(null), []);
const contextValue = useMemo(
@@ -235,6 +277,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login,
logout,
selectOrganization,
createOrganization,
clearError,
}),
[
@@ -248,6 +291,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login,
logout,
selectOrganization,
createOrganization,
clearError,
],
);

View File

@@ -2,9 +2,9 @@
/* Light theme tokens (future-ready) */
:root[data-theme="light"] {
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--color-background-primary: #f6f9fc;
--color-background-secondary: #ffffff;
@@ -45,7 +45,7 @@
--color-primary: #09a9bc;
--color-primary-contrast: #001117;
--color-primary-soft: rgba(9, 169, 188, 0.2);
--color-icon: #f3bb4b;
--color-icon: #e1bc72;
}
/* Default theme = dark */

View File

@@ -31,6 +31,7 @@ export interface TrialRegistrationData {
password: string;
name: string;
organizationName: string;
organizationEmail: string;
organizationType: 'CLINIC' | 'LAB';
}