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 googleId String? @unique
facebookId String? @unique facebookId String? @unique
name String name String
trialUsedAt DateTime?
memberships Membership[] memberships Membership[]
ownedOrganizations Organization[] @relation("OrganizationOwner") ownedOrganizations Organization[] @relation("OrganizationOwner")

View File

@@ -25,6 +25,7 @@ import {
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'; import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-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 // PROFILE
// ========================= // =========================

View File

@@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [ const ALL_PERMISSIONS = [
@@ -176,7 +177,7 @@ export class AuthService {
* @returns Created user info without password * @returns Created user info without password
*/ */
async register(registerDto: RegisterDto) { async register(registerDto: RegisterDto) {
const { email, password, name, organizationName, organizationType } = registerDto; const { email, password, name, organizationName, organizationEmail, organizationType } = registerDto;
// 1. Check existing user // 1. Check existing user
const existingUser = await this.prisma.user.findUnique({ const existingUser = await this.prisma.user.findUnique({
@@ -184,7 +185,7 @@ export class AuthService {
}); });
if (existingUser) { 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 // 2. Hash password
@@ -198,16 +199,15 @@ export class AuthService {
email, email,
passwordHash: hashedPassword, passwordHash: hashedPassword,
name, name,
trialUsedAt: new Date(),
}, },
}); });
// Create organization // Create organization
const organization = await tx.organization.create({ const organization = await tx.organization.create({
data: { data: {
name: registerDto.organizationName, name: organizationName,
email: organizationEmail,
// REQUIRED FIELDS 👇
email: registerDto.email, // or separate org email if you have one
owner: { owner: {
connect: { id: user.id }, connect: { id: user.id },
@@ -219,7 +219,7 @@ export class AuthService {
type: { type: {
connect: { 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); 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 * Get user profile with all memberships and permissions
* @param userId - User ID from JWT token * @param userId - User ID from JWT token
@@ -617,6 +677,7 @@ export class AuthService {
organizationId, organizationId,
}, },
include: { include: {
user: true,
organization: { organization: {
include: { include: {
type: true, type: true,
@@ -638,7 +699,7 @@ export class AuthService {
// 2. Build payload WITH org context // 2. Build payload WITH org context
const payload = { const payload = {
sub: userId, sub: userId,
email: membership.organization.email, email: membership.user.email,
organizationId: membership.organizationId, organizationId: membership.organizationId,
type: 'access', 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() @IsString()
organizationName: string; organizationName: string;
@IsEmail()
organizationEmail: string;
@IsEnum(['CLINIC', 'LAB']) @IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB'; organizationType: 'CLINIC' | 'LAB';
} }

View File

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

View File

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

View File

@@ -1,18 +1,17 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth'; 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() { export default function SelectOrganizationPage() {
const { organizations, selectOrganization, isLoading } = useAuth(); const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth();
const [isCreateOpen, setIsCreateOpen] = useState(false);
// ✅ Auto-redirect if only one organization const [organizationName, setOrganizationName] = useState('');
useEffect(() => { const [organizationEmail, setOrganizationEmail] = useState('');
if (!isLoading && organizations.length === 1) { const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC');
selectOrganization(organizations[0].id);
}
}, [organizations, isLoading]);
const getIcon = (type: string) => { const getIcon = (type: string) => {
return type === 'CLINIC' return type === 'CLINIC'
@@ -20,60 +19,152 @@ export default function SelectOrganizationPage() {
: <Beaker className="h-8 w-8 icon-flat" />; : <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) { if (isLoading) {
return ( return (
<div className="min-h-screen app-web-bg flex items-center justify-center"> <div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading organizations...</p> <p className="text-text-secondary">Loading...</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>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen app-web-bg flex items-center justify-center p-4"> <div className="min-h-screen app-web-bg p-4 sm:p-8">
<div className="max-w-2xl w-full"> <div className="max-w-3xl mx-auto">
<div className="text-center mb-8"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<h1 className="text-3xl font-semibold text-text-primary"> <div>
Choose Organization <h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
</h1> <p className="text-text-secondary mt-2">
<p className="text-text-secondary mt-2"> Select an organization to continue, or create a new one.
You have access to multiple organizations. Select one to continue. </p>
</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>
<div className="grid gap-4"> {isCreateOpen && (
{organizations.map((org) => ( <div className="surface-card p-6 mb-6 space-y-4">
<button <Input
key={org.id} label="Organization name"
onClick={() => selectOrganization(org.id)} value={organizationName}
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60" onChange={(event) => setOrganizationName(event.target.value)}
> placeholder="Sunshine Dental Clinic"
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary"> icon={<Building2 className="h-5 w-5 icon-flat" />}
{getIcon(org.type)} />
<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>
</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"> {!organizations.length ? (
<h3 className="text-lg font-semibold text-text-primary"> <div className="surface-card p-8 text-center">
{org.name} <p className="text-text-secondary">No organizations found. Create your first one to continue.</p>
</h3> </div>
<p className="text-sm text-text-secondary"> ) : (
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'} <div className="grid gap-4">
</p> {organizations.map((org) => (
</div> <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"> <div className="flex-1">
Continue <h3 className="text-lg font-semibold text-text-primary">
</div> {org.name}
</button> </h3>
))} <p className="text-sm text-text-secondary">
</div> {org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
<div className="text-primary text-sm">
Continue
</div>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -27,6 +27,17 @@ export const authApi = {
return response.data; 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
logout: async (): Promise<void> => { logout: async (): Promise<void> => {
await apiClient.post('/auth/logout'); await apiClient.post('/auth/logout');

View File

@@ -17,11 +17,18 @@ interface AuthContextType {
password: string, password: string,
name: string, name: string,
organizationName: string, organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB' organizationType: 'CLINIC' | 'LAB'
) => Promise<void>; ) => Promise<void>;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
selectOrganization: (orgId: string) => Promise<void>; selectOrganization: (orgId: string) => Promise<void>;
createOrganization: (
organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB',
planName?: string,
) => Promise<string>;
clearError: () => void; clearError: () => void;
} }
@@ -111,6 +118,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password: string, password: string,
name: string, name: string,
organizationName: string, organizationName: string,
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB' organizationType: 'CLINIC' | 'LAB'
) => { ) => {
try { try {
@@ -122,6 +130,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password, password,
name, name,
organizationName, organizationName,
organizationEmail,
organizationType, organizationType,
}); });
@@ -221,6 +230,39 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}, [router]); }, [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 clearError = useCallback(() => setError(null), []);
const contextValue = useMemo( const contextValue = useMemo(
@@ -235,6 +277,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login, login,
logout, logout,
selectOrganization, selectOrganization,
createOrganization,
clearError, clearError,
}), }),
[ [
@@ -248,6 +291,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login, login,
logout, logout,
selectOrganization, selectOrganization,
createOrganization,
clearError, clearError,
], ],
); );

View File

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

View File

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