+
+
Invitations
+
+
+
+ {showInviteForm && (
+
+
+
+ setInviteEmail(e.target.value)}
+ className="w-full px-3 py-2 bg-background border rounded-md text-sm"
+ placeholder="user@example.com"
+ />
+
+
+
+
+
+ {sendInviteMutation.isError && (
+
+ {(sendInviteMutation.error as Error).message}
+
+ )}
+
+
+
+
+
+ )}
+
+ {invites.length === 0 ? (
+
+ No invitations sent yet.
+
+ ) : (
+
+
+
+
+ | Email |
+ Role |
+ Status |
+ Actions |
+
+
+
+ {invites.map((inv) => (
+
+ | {inv.email} |
+ {inv.role} |
+
+
+ {inv.status}
+
+ |
+
+ {inv.status === 'pending' && (
+
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
{/* Right column */}
{/* Quick Actions */}
diff --git a/it0-web-admin/src/app/(auth)/invite/[token]/page.tsx b/it0-web-admin/src/app/(auth)/invite/[token]/page.tsx
new file mode 100644
index 0000000..c3f8985
--- /dev/null
+++ b/it0-web-admin/src/app/(auth)/invite/[token]/page.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter, useParams } from 'next/navigation';
+import Link from 'next/link';
+import { apiClient } from '@/infrastructure/api/api-client';
+
+interface InviteInfo {
+ email: string;
+ tenantName: string;
+ role: string;
+ expiresAt: string;
+}
+
+interface AcceptResponse {
+ accessToken: string;
+ refreshToken: string;
+ user: { id: string; email: string; name: string; roles: string[]; tenantId: string };
+}
+
+export default function AcceptInvitePage() {
+ const router = useRouter();
+ const params = useParams();
+ const token = params.token as string;
+
+ const [invite, setInvite] = useState
(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const [name, setName] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ useEffect(() => {
+ async function validateInvite() {
+ try {
+ const data = await apiClient(`/api/v1/auth/invite/${token}`);
+ setInvite(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Invalid invitation');
+ } finally {
+ setLoading(false);
+ }
+ }
+ validateInvite();
+ }, [token]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (password !== confirmPassword) {
+ setSubmitError('Passwords do not match');
+ return;
+ }
+ if (password.length < 6) {
+ setSubmitError('Password must be at least 6 characters');
+ return;
+ }
+
+ setIsSubmitting(true);
+ setSubmitError(null);
+
+ try {
+ const data = await apiClient('/api/v1/auth/accept-invite', {
+ method: 'POST',
+ body: { token, password, name },
+ });
+
+ localStorage.setItem('access_token', data.accessToken);
+ localStorage.setItem('refresh_token', data.refreshToken);
+ localStorage.setItem('user', JSON.stringify(data.user));
+
+ try {
+ const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
+ if (payload.tenantId) {
+ localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId }));
+ }
+ } catch { /* ignore */ }
+
+ router.push('/dashboard');
+ } catch (err) {
+ setSubmitError(err instanceof Error ? err.message : 'Failed to accept invitation');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
Validating invitation...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Invalid Invitation
+
{error}
+
+ Go to Login
+
+
+ );
+ }
+
+ return (
+
+
+
IT0
+
Accept Invitation
+
+
+
+
+ Organization:{' '}
+ {invite?.tenantName}
+
+
+ Email:{' '}
+ {invite?.email}
+
+
+ Role:{' '}
+ {invite?.role}
+
+
+
+
+
+
+ Already have an account?{' '}
+
+ Sign in
+
+
+
+ );
+}
diff --git a/it0-web-admin/src/app/(auth)/login/page.tsx b/it0-web-admin/src/app/(auth)/login/page.tsx
index d916a43..72929c9 100644
--- a/it0-web-admin/src/app/(auth)/login/page.tsx
+++ b/it0-web-admin/src/app/(auth)/login/page.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
+import Link from 'next/link';
import { apiClient } from '@/infrastructure/api/api-client';
interface LoginResponse {
@@ -87,6 +88,13 @@ export default function LoginPage() {
{isLoading ? 'Signing in...' : 'Sign In'}
+
+
+ Don't have an account?{' '}
+
+ Create one
+
+
);
}
diff --git a/it0-web-admin/src/app/(auth)/register/page.tsx b/it0-web-admin/src/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..7564370
--- /dev/null
+++ b/it0-web-admin/src/app/(auth)/register/page.tsx
@@ -0,0 +1,162 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { apiClient } from '@/infrastructure/api/api-client';
+
+interface RegisterResponse {
+ accessToken: string;
+ refreshToken: string;
+ user: { id: string; email: string; name: string; roles: string[]; tenantId: string };
+}
+
+export default function RegisterPage() {
+ const router = useRouter();
+ const [name, setName] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [companyName, setCompanyName] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState