Bernt Christian Egeland 7 ماه پیش
والد
کامیت
85cc400dc4

+ 0 - 1
.devcontainer/devcontainer.json

@@ -18,7 +18,6 @@
         "esbenp.prettier-vscode"
       ],
       "settings": {
-        "editor.defaultFormatter": "esbenp.prettier-vscode",
         "editor.formatOnSave": true
       }
     }

+ 7 - 0
.devcontainer/docker-compose.yml

@@ -13,6 +13,9 @@ services:
       - 3000:3000
     environment:
       - DATABASE_URL=postgresql://torqvoice:torqvoice@db:5432/torqvoice
+    networks:
+      - default
+      - torqvoice-shared
     depends_on:
       db:
         condition: service_healthy
@@ -55,3 +58,7 @@ services:
 volumes:
   postgres-data:
   pgadmin-data:
+
+networks:
+  torqvoice-shared:
+    external: true

+ 26 - 0
prisma/migrations/20260216075347_user_invitations/migration.sql

@@ -0,0 +1,26 @@
+-- CreateTable
+CREATE TABLE "team_invitations" (
+    "id" TEXT NOT NULL,
+    "email" TEXT NOT NULL,
+    "role" TEXT NOT NULL DEFAULT 'member',
+    "token" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'pending',
+    "expiresAt" TIMESTAMP(3) NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "organizationId" TEXT NOT NULL,
+    "invitedById" TEXT NOT NULL,
+
+    CONSTRAINT "team_invitations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "team_invitations_token_key" ON "team_invitations"("token");
+
+-- CreateIndex
+CREATE INDEX "team_invitations_token_idx" ON "team_invitations"("token");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "team_invitations_email_organizationId_key" ON "team_invitations"("email", "organizationId");
+
+-- AddForeignKey
+ALTER TABLE "team_invitations" ADD CONSTRAINT "team_invitations_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 18 - 0
prisma/schema.prisma

@@ -474,6 +474,7 @@ model Organization {
   customFieldDefinitions CustomFieldDefinition[]
   settings               AppSetting[]
   subscription           Subscription?
+  invitations            TeamInvitation[]
 
   @@map("organizations")
 }
@@ -531,6 +532,23 @@ model SystemSetting {
   @@map("system_settings")
 }
 
+model TeamInvitation {
+  id             String       @id @default(cuid())
+  email          String
+  role           String       @default("member")
+  token          String       @unique
+  status         String       @default("pending") // pending | accepted | cancelled
+  expiresAt      DateTime
+  createdAt      DateTime     @default(now())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  invitedById    String
+
+  @@unique([email, organizationId])
+  @@index([token])
+  @@map("team_invitations")
+}
+
 // Stripe-ready models (implementation in follow-up)
 
 model SubscriptionPlan {

+ 5 - 1
src/app/(authenticated)/settings/team/page.tsx

@@ -1,20 +1,24 @@
 import { getOrganization } from "@/features/team/Actions/teamActions";
 import { getRoles } from "@/features/team/Actions/getRoles";
+import { getPendingInvitations } from "@/features/team/Actions/getPendingInvitations";
 import { TeamSettings } from "./team-settings";
 
 export default async function TeamPage() {
-  const [result, rolesResult] = await Promise.all([
+  const [result, rolesResult, invitationsResult] = await Promise.all([
     getOrganization(),
     getRoles(),
+    getPendingInvitations(),
   ]);
   const orgData = result.success ? result.data : null;
   const roles = rolesResult.success && rolesResult.data ? rolesResult.data : [];
+  const pendingInvitations = invitationsResult.success && invitationsResult.data ? invitationsResult.data : [];
 
   return (
     <TeamSettings
       organization={orgData?.organization || null}
       currentRole={orgData?.currentRole || null}
       roles={roles}
+      pendingInvitations={pendingInvitations}
     />
   );
 }

+ 105 - 4
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -24,12 +24,14 @@ import {
   updateMemberRole,
   removeMember,
 } from "@/features/team/Actions/teamActions";
+import { sendInvitation } from "@/features/team/Actions/sendInvitation";
+import { cancelInvitation } from "@/features/team/Actions/cancelInvitation";
 import { createRole } from "@/features/team/Actions/createRole";
 import { updateRole } from "@/features/team/Actions/updateRole";
 import { deleteRole } from "@/features/team/Actions/deleteRole";
 import { assignRole } from "@/features/team/Actions/assignRole";
 import { permissionGroups } from "@/lib/permissions";
-import { Crown, Loader2, Pencil, Plus, Shield, ShieldCheck, Trash2, User, Users } from "lucide-react";
+import { Crown, Loader2, Mail, Pencil, Plus, Shield, ShieldCheck, Trash2, User, Users, X } from "lucide-react";
 
 interface Member {
   id: string;
@@ -44,6 +46,14 @@ interface Organization {
   members: Member[];
 }
 
+interface PendingInvitation {
+  id: string;
+  email: string;
+  role: string;
+  createdAt: Date;
+  expiresAt: Date;
+}
+
 interface RoleData {
   id: string;
   name: string;
@@ -68,10 +78,12 @@ export function TeamSettings({
   organization,
   currentRole,
   roles = [],
+  pendingInvitations = [],
 }: {
   organization: Organization | null;
   currentRole: string | null;
   roles?: RoleData[];
+  pendingInvitations?: PendingInvitation[];
 }) {
   const router = useRouter();
   const modal = useGlassModal();
@@ -209,15 +221,57 @@ export function TeamSettings({
     setLoading(true);
     const result = await inviteMember({ email: inviteEmail, role: inviteRole });
     if (result.success) {
-      setInviteEmail("");
-      router.refresh();
-      modal.open("success", "Invited", `Member invited successfully.`);
+      const data = result.data as { invited: boolean; userNotFound?: boolean };
+      if (data.userNotFound) {
+        // User doesn't exist — ask to send invitation email
+        const emailToInvite = inviteEmail;
+        const roleToInvite = inviteRole;
+        setLoading(false);
+        const ok = await confirm({
+          title: "User Not Found",
+          description: `No account found for "${emailToInvite}". Send them an invitation email to sign up and join your team?`,
+          confirmLabel: "Send Invitation",
+        });
+        if (ok) {
+          setLoading(true);
+          const sendResult = await sendInvitation({ email: emailToInvite, role: roleToInvite });
+          if (sendResult.success) {
+            setInviteEmail("");
+            router.refresh();
+            modal.open("success", "Invitation Sent", `An invitation email has been sent to ${emailToInvite}.`);
+          } else {
+            modal.open("error", "Error", sendResult.error || "Failed to send invitation");
+          }
+          setLoading(false);
+        }
+      } else {
+        setInviteEmail("");
+        router.refresh();
+        modal.open("success", "Invited", `Member invited successfully.`);
+      }
     } else {
       modal.open("error", "Error", result.error || "Failed to invite member");
     }
     setLoading(false);
   };
 
+  const handleCancelInvitation = async (invitation: PendingInvitation) => {
+    const ok = await confirm({
+      title: "Cancel Invitation",
+      description: `Cancel the invitation to ${invitation.email}? They will no longer be able to use this link to join.`,
+      confirmLabel: "Cancel Invitation",
+      destructive: true,
+    });
+    if (!ok) return;
+    const result = await cancelInvitation({ invitationId: invitation.id });
+    if (result.success) {
+      toast.success("Invitation cancelled");
+      router.refresh();
+    } else {
+      modal.open("error", "Error", result.error || "Failed to cancel invitation");
+    }
+  };
+
   const handleRoleChange = async (memberId: string, role: string) => {
     const result = await updateMemberRole({ memberId, role });
     if (result.success) {
@@ -402,6 +456,53 @@ export function TeamSettings({
         </CardContent>
       </Card>
 
+      {/* Pending Invitations Card */}
+      {isAdmin && pendingInvitations.length > 0 && (
+        <Card className="border-0 shadow-sm">
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2 text-base">
+              <Mail className="h-4 w-4" /> Pending Invitations
+              <Badge variant="outline" className="ml-2 text-xs">
+                {pendingInvitations.length}
+              </Badge>
+            </CardTitle>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-2">
+              {pendingInvitations.map((invitation) => (
+                <div
+                  key={invitation.id}
+                  className="flex items-center gap-3 rounded-lg border p-3"
+                >
+                  <div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-sm">
+                    <Mail className="h-4 w-4 text-muted-foreground" />
+                  </div>
+                  <div className="min-w-0 flex-1">
+                    <p className="truncate font-medium text-sm">{invitation.email}</p>
+                    <p className="text-xs text-muted-foreground">
+                      Invited as {invitation.role} &middot; Expires {new Date(invitation.expiresAt).toLocaleDateString()}
+                    </p>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <Badge variant="outline" className="text-xs bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
+                      Pending
+                    </Badge>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-8 w-8 text-muted-foreground hover:text-destructive"
+                      onClick={() => handleCancelInvitation(invitation)}
+                    >
+                      <X className="h-3.5 w-3.5" />
+                    </Button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
       {/* Custom Roles Card */}
       {isAdmin && (
         <Card className="border-0 shadow-sm">

+ 18 - 8
src/app/(public)/auth/sign-up/page.tsx

@@ -4,15 +4,25 @@ import { SignUpForm } from './sign-up-form'
 
 export const dynamic = 'force-dynamic'
 
-export default async function SignUpPage() {
-  const regSetting = await db.systemSetting.findUnique({
-    where: { key: 'registration.disabled' },
-    select: { value: true },
-  })
+export default async function SignUpPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ invite?: string }>
+}) {
+  const params = await searchParams
+  const inviteToken = params.invite
 
-  if (regSetting?.value === 'true') {
-    redirect('/auth/sign-in')
+  // If there's an invite token, skip the registration-disabled check
+  if (!inviteToken) {
+    const regSetting = await db.systemSetting.findUnique({
+      where: { key: 'registration.disabled' },
+      select: { value: true },
+    })
+
+    if (regSetting?.value === 'true') {
+      redirect('/auth/sign-in')
+    }
   }
 
-  return <SignUpForm />
+  return <SignUpForm inviteToken={inviteToken} />
 }

+ 17 - 3
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -9,8 +9,9 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { useGlassModal } from '@/components/glass-modal'
 import { Gauge, Loader2 } from 'lucide-react'
+import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 
-export function SignUpForm() {
+export function SignUpForm({ inviteToken }: { inviteToken?: string }) {
   const [name, setName] = useState('')
   const [email, setEmail] = useState('')
   const [password, setPassword] = useState('')
@@ -36,6 +37,17 @@ export function SignUpForm() {
             ? 'Registration is currently disabled. Please contact your administrator.'
             : message,
         )
+      } else if (inviteToken) {
+        // Accept the invitation and redirect to dashboard
+        const acceptResult = await acceptInvitation({ token: inviteToken })
+        if (acceptResult.success) {
+          router.push('/')
+          router.refresh()
+        } else {
+          modal.open('error', 'Invitation Error', acceptResult.error || 'Failed to accept invitation')
+          router.push('/onboarding')
+          router.refresh()
+        }
       } else {
         router.push('/onboarding')
         router.refresh()
@@ -64,7 +76,9 @@ export function SignUpForm() {
           </div>
           <h1 className="text-2xl font-bold tracking-tight">Create an account</h1>
           <p className="mt-1 text-sm text-muted-foreground">
-            Set up your workshop to manage customer vehicles
+            {inviteToken
+              ? 'Create your account to join the team'
+              : 'Set up your workshop to manage customer vehicles'}
           </p>
         </div>
 
@@ -111,7 +125,7 @@ export function SignUpForm() {
 
           <Button type="submit" className="h-11 w-full" disabled={loading}>
             {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
-            Create Account
+            {inviteToken ? 'Create Account & Join Team' : 'Create Account'}
           </Button>
         </form>
 

+ 80 - 0
src/features/team/Actions/acceptInvitation.ts

@@ -0,0 +1,80 @@
+"use server";
+
+import { headers, cookies } from "next/headers";
+import { auth } from "@/lib/auth";
+import { db } from "@/lib/db";
+import { acceptInvitationSchema } from "../Schema/teamSchema";
+
+export async function acceptInvitation(input: unknown) {
+  try {
+    const data = acceptInvitationSchema.parse(input);
+
+    const session = await auth.api.getSession({ headers: await headers() });
+    if (!session?.user?.id) {
+      return { success: false, error: "You must be signed in to accept an invitation" };
+    }
+
+    const invitation = await db.teamInvitation.findUnique({
+      where: { token: data.token },
+    });
+
+    if (!invitation) {
+      return { success: false, error: "Invitation not found" };
+    }
+
+    if (invitation.status !== "pending") {
+      return { success: false, error: "This invitation is no longer valid" };
+    }
+
+    if (invitation.expiresAt < new Date()) {
+      return { success: false, error: "This invitation has expired" };
+    }
+
+    if (invitation.email !== session.user.email) {
+      return { success: false, error: "This invitation was sent to a different email address" };
+    }
+
+    // Check if already a member
+    const existingMembership = await db.organizationMember.findFirst({
+      where: { userId: session.user.id, organizationId: invitation.organizationId },
+    });
+    if (existingMembership) {
+      // Mark invitation as accepted even if already a member
+      await db.teamInvitation.update({
+        where: { id: invitation.id },
+        data: { status: "accepted" },
+      });
+      return { success: true, data: { accepted: true } };
+    }
+
+    // Create membership and mark invitation as accepted
+    await db.$transaction([
+      db.organizationMember.create({
+        data: {
+          userId: session.user.id,
+          organizationId: invitation.organizationId,
+          role: invitation.role,
+        },
+      }),
+      db.teamInvitation.update({
+        where: { id: invitation.id },
+        data: { status: "accepted" },
+      }),
+    ]);
+
+    // Set the active org cookie
+    const cookieStore = await cookies();
+    cookieStore.set("active-org-id", invitation.organizationId, {
+      httpOnly: true,
+      sameSite: "lax",
+      path: "/",
+      maxAge: 60 * 60 * 24 * 365,
+    });
+
+    return { success: true, data: { accepted: true } };
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "An unexpected error occurred";
+    console.error("[acceptInvitation] Error:", message);
+    return { success: false, error: message };
+  }
+}

+ 33 - 0
src/features/team/Actions/cancelInvitation.ts

@@ -0,0 +1,33 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { cancelInvitationSchema } from "../Schema/teamSchema";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+
+export async function cancelInvitation(input: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const data = cancelInvitationSchema.parse(input);
+
+    // Verify caller is owner or admin
+    const membership = await db.organizationMember.findFirst({
+      where: { userId, organizationId },
+    });
+    if (!membership) throw new Error("You don't belong to an organization");
+    if (membership.role === "member") throw new Error("Only owners and admins can cancel invitations");
+
+    const invitation = await db.teamInvitation.findFirst({
+      where: { id: data.invitationId, organizationId, status: "pending" },
+    });
+    if (!invitation) throw new Error("Invitation not found");
+
+    await db.teamInvitation.update({
+      where: { id: data.invitationId },
+      data: { status: "cancelled" },
+    });
+
+    revalidatePath("/settings/team");
+    return { cancelled: true };
+  }, { requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }] });
+}

+ 27 - 0
src/features/team/Actions/getPendingInvitations.ts

@@ -0,0 +1,27 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function getPendingInvitations() {
+  return withAuth(async ({ organizationId }) => {
+    const invitations = await db.teamInvitation.findMany({
+      where: {
+        organizationId,
+        status: "pending",
+        expiresAt: { gt: new Date() },
+      },
+      orderBy: { createdAt: "desc" },
+      select: {
+        id: true,
+        email: true,
+        role: true,
+        createdAt: true,
+        expiresAt: true,
+      },
+    });
+
+    return invitations;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }] });
+}

+ 111 - 0
src/features/team/Actions/sendInvitation.ts

@@ -0,0 +1,111 @@
+"use server";
+
+import { randomUUID } from "crypto";
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { sendInvitationSchema } from "../Schema/teamSchema";
+import { sendMail } from "@/lib/email";
+import { SYSTEM_SETTING_KEYS } from "@/features/admin/Schema/systemSettingsSchema";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+
+export async function sendInvitation(input: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const data = sendInvitationSchema.parse(input);
+
+    // Verify caller is owner or admin
+    const membership = await db.organizationMember.findFirst({
+      where: { userId, organizationId },
+      include: { organization: true },
+    });
+    if (!membership) throw new Error("You don't belong to an organization");
+    if (membership.role === "member") throw new Error("Only owners and admins can invite members");
+
+    // Check for existing pending invitation
+    const existing = await db.teamInvitation.findFirst({
+      where: {
+        email: data.email,
+        organizationId,
+        status: "pending",
+      },
+    });
+    if (existing) throw new Error("An invitation has already been sent to this email");
+
+    // Create invitation with 7-day expiry
+    const token = randomUUID();
+    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+    await db.teamInvitation.create({
+      data: {
+        email: data.email,
+        role: data.role,
+        token,
+        expiresAt,
+        organizationId,
+        invitedById: userId,
+      },
+    });
+
+    // Resolve from address
+    const providerSetting = await db.systemSetting.findUnique({
+      where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
+    });
+    const provider = providerSetting?.value === "resend" ? "resend" : "smtp";
+
+    let fromEmail: string;
+    let fromName: string;
+
+    if (provider === "resend") {
+      const emailSetting = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL },
+      });
+      const nameSetting = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_NAME },
+      });
+      fromEmail = emailSetting?.value || "noreply@example.com";
+      fromName = nameSetting?.value || "Torqvoice";
+    } else {
+      const emailSetting = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL },
+      });
+      const nameSetting = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME },
+      });
+      fromEmail =
+        emailSetting?.value ||
+        process.env.SMTP_FROM_EMAIL ||
+        "noreply@example.com";
+      fromName = nameSetting?.value || "Torqvoice";
+    }
+
+    const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
+    const signupUrl = `${baseUrl}/auth/sign-up?invite=${token}`;
+
+    await sendMail({
+      from: `${fromName} <${fromEmail}>`,
+      to: data.email,
+      subject: `You've been invited to join ${membership.organization.name} on Torqvoice`,
+      html: `
+        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+          <h2>Team Invitation</h2>
+          <p>You've been invited to join <strong>${membership.organization.name}</strong> on Torqvoice as a <strong>${data.role}</strong>.</p>
+          <p>Click the button below to create your account and join the team:</p>
+          <div style="margin: 24px 0;">
+            <a href="${signupUrl}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
+              Accept Invitation
+            </a>
+          </div>
+          <p style="color: #6b7280; font-size: 14px;">This invitation expires in 7 days.</p>
+          <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+          <p style="color: #6b7280; font-size: 12px;">
+            If the button doesn't work, copy and paste this URL into your browser:<br/>
+            <a href="${signupUrl}" style="color: #6b7280;">${signupUrl}</a>
+          </p>
+        </div>
+      `,
+    });
+
+    revalidatePath("/settings/team");
+    return { invited: true, pending: true };
+  }, { requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }] });
+}

+ 1 - 1
src/features/team/Actions/teamActions.ts

@@ -109,7 +109,7 @@ export async function inviteMember(input: unknown) {
     const invitedUser = await db.user.findFirst({
       where: { email: data.email },
     });
-    if (!invitedUser) throw new Error("No user found with that email. They must sign up first.");
+    if (!invitedUser) return { invited: false, userNotFound: true };
 
     // Check if already a member of this org
     const existingMembership = await db.organizationMember.findFirst({

+ 13 - 0
src/features/team/Schema/teamSchema.ts

@@ -46,3 +46,16 @@ export const assignRoleSchema = z.object({
   memberId: z.string(),
   roleId: z.string().nullable(),
 });
+
+export const sendInvitationSchema = z.object({
+  email: z.string().email(),
+  role: z.enum(["admin", "member"]).default("member"),
+});
+
+export const cancelInvitationSchema = z.object({
+  invitationId: z.string(),
+});
+
+export const acceptInvitationSchema = z.object({
+  token: z.string(),
+});

+ 11 - 1
src/lib/auth.ts

@@ -99,7 +99,17 @@ export const auth = betterAuth({
             where: { key: "registration.disabled" },
           });
           if (setting?.value === "true") {
-            return false;
+            // Allow registration if there's a pending invitation for this email
+            const invitation = await db.teamInvitation.findFirst({
+              where: {
+                email: user.email,
+                status: "pending",
+                expiresAt: { gt: new Date() },
+              },
+            });
+            if (!invitation) {
+              return false;
+            }
           }
           return { data: user };
         },