feat/onboarding (#337)

* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Théo LAGACHE
2026-06-27 17:08:38 +02:00
committed by GitHub
co-authored by Théo LAGACHE charles-gauthereau
parent 40d3228db6
commit 40247efddd
451 changed files with 15904 additions and 5054 deletions
@@ -0,0 +1,43 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {ButtonDeleteOrganization} from "@/features/organizations/components/admin-organization-delete-button";
import Link from "next/link";
import {Settings} from "lucide-react";
import {buttonVariants} from "@/components/ui/button";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export function organizationsListColumns(): ColumnDef<OrganizationWithMembers>[] {
return [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "members",
header: "Members",
cell: ({row}) => {
const membersCount = row.original.members?.length;
return <div className="flex items-center gap-3">{membersCount}</div>;
},
},
{
header: "Actions",
id: "actions",
cell: ({row}) => {
const isDefaultOrganization = row.original.slug == "default";
return (
<div className="flex items-center gap-3">
{!isDefaultOrganization && (
<ButtonDeleteOrganization organisationId={row.original.id}/>
)}
<Link className={buttonVariants({variant: "outline"})}
href={`organizations/${row.original.id}`}>
<Settings/>
</Link>
</div>
);
},
},
];
}
@@ -0,0 +1,49 @@
"use client"
import {CreateOrganizationModal} from "@/features/organizations/components/organization-create-modal";
import {useRouter} from "next/navigation";
import {authClient} from "@/lib/auth/auth-client";
import {useState} from "react";
import {Button} from "@/components/ui/button";
import {Plus} from "lucide-react";
type AdminOrganizationAddModalProps = {}
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
const [openModal, setOpenModal] = useState(false);
if (!organizations) return null;
const handleReload = () => {
refetch();
refetchActiveOrga();
router.refresh();
};
const handleOpen = () => {
setOpenModal(true);
}
return (
<>
<Button onClick={handleOpen}>
<Plus/> Create a new organization
</Button>
<CreateOrganizationModal
redirect={"/dashboard/admin/organizations"}
open={openModal}
onSuccess={handleReload}
onOpenChange={setOpenModal}
/>
</>
)
}
@@ -0,0 +1,70 @@
"use client"
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Trash2} from "lucide-react";
import {toast} from "sonner";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {authClient} from "@/lib/auth/auth-client";
import {deleteOrganizationAction} from "@/features/organizations/actions/organization.action";
export type ButtonDeleteFleetProps = {
text?: string;
organisationId: string
};
export const ButtonDeleteOrganization = (props: ButtonDeleteFleetProps) => {
const router = useRouter();
const {refetch} = authClient.useListOrganizations();
const mutationDeleteOrganisation = useMutation({
mutationFn: () => deleteOrganizationAction({id: props.organisationId}),
onSuccess: async (result) => {
if (result?.data?.success) {
await authClient.organization.setActive({
organizationSlug: "default",
});
toast.success("Organization deleted!");
router.refresh()
refetch()
} else {
toast.error("An error occurred.");
}
},
onError: (_e: any) => {
toast.error(_e?.message || "A network error occurred.");
},
});
return (
<ButtonWithConfirm
title={props.text ? props.text : ""}
description={"Are you sure you want to delete this organization?"}
button={{
main: {
variant: "outline",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: async () => {
await mutationDeleteOrganisation.mutateAsync()
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
isPending={mutationDeleteOrganisation.isPending}
/>
);
};
@@ -0,0 +1,84 @@
"use client";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { OrganizationSchema } from "@/features/organizations/schemas/admin-organization.schema";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/auth-client";
import { slugify } from "@/utils/slugify";
type AdminOrganizationFormProps = {
onSuccess?: () => void;
};
export const AdminOrganizationForm = ({ onSuccess }: AdminOrganizationFormProps) => {
const router = useRouter();
const form = useZodForm({ schema: OrganizationSchema });
const mutationCreateOrganisation = useMutation({
mutationFn: async ({ name }: OrganizationSchema) => {
const slug = slugify(name);
await authClient.organization.checkSlug(
{
slug: slug,
},
{
onSuccess: async () => {
await authClient.organization.create(
{
name: name,
slug: slug,
},
{
onSuccess: () => {
toast.success("Organization created successfully.");
router.refresh();
onSuccess?.();
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
},
onError: (error: { error: { message: string } }) => {
toast.error(error.error.message);
onSuccess?.();
},
}
);
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationCreateOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name of your organization" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>Validate</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,12 @@
"use client"
import { DataTable } from "@/components/common/data-table";
import { organizationsListColumns } from "@/features/organizations/components/admin-org-columns";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationListProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationList = ({ organizations }: AdminOrganizationListProps) => {
return <DataTable columns={organizationsListColumns()} data={organizations} enablePagination={true} enableSelect={false} />;
};
@@ -0,0 +1,116 @@
"use client";
import {Building2, Shield, Users} from "lucide-react";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {
UpdateOrganizationForm
} from "@/features/organizations/components/update-organization-form";
import {
OrganizationMemberCard
} from "@/features/organizations/components/organization-member-card";
import {useRouter, useSearchParams} from "next/navigation";
import {useEffect, useState} from "react";
import {capitalizeFirstLetter} from "@/utils/text";
import {User} from "@/db/schema/02_user";
import {
OrganizationAddMemberModal
} from "@/features/organizations/components/organization-add-member-modal";
import {cn} from "@/lib/utils";
type OrganizationManagementProps = {
organization: OrganizationWithMembersAndUsers;
users: User[];
};
export const OrganizationManagement = ({organization, users}: OrganizationManagementProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "members");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "members";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return (
<div className=" space-y-8">
<div className="flex flex-col md:flex-row md:items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center justify-center w-12 h-12 dark:bg-gray-700 bg-gray-100 rounded-lg">
<Building2 className="w-6 h-6 "/>
</div>
<div>
<h1 className="text-2xl font-bold">{capitalizeFirstLetter(organization.name)}</h1>
</div>
</div>
<div className="flex items-center space-x-2 mt-3 md:mt-0">
<OrganizationAddMemberModal organization={organization} users={users}/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{organization.members.length}</div>
<p className="text-xs text-muted-foreground">Number of members</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Administrators</CardTitle>
<Shield className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div
className="text-2xl font-bold">{organization.members.filter((m) => m.role === "admin" || m.role === "owner").length}</div>
<p className="text-xs text-muted-foreground">With admin roles</p>
</CardContent>
</Card>
</div>
<Tabs className="space-y-6" value={tab} onValueChange={handleChangeTab}>
<TabsList>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="members" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Organization members</CardTitle>
<CardDescription>Manage who has access to your organization and their
roles.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{organization.members.map((member: MemberWithUser) => (
<OrganizationMemberCard key={member.id} member={member}
organization={organization}/>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
<CardDescription>Organization configuration settings.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<UpdateOrganizationForm defaultValues={organization}/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
@@ -0,0 +1,27 @@
"use client";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminOrganizationList } from "@/features/organizations/components/admin-organization-list";
import { AdminOrganizationAddModal } from "@/features/organizations/components/admin-organization-add-modal";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationSectionProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationSection = ({ organizations }: AdminOrganizationSectionProps) => {
return (
<Card>
<CardHeader>
<CardTitle>Add a new organization</CardTitle>
<CardAction>
<AdminOrganizationAddModal />
</CardAction>
</CardHeader>
<CardContent>
<AdminOrganizationList organizations={organizations} />
</CardContent>
</Card>
);
};
@@ -0,0 +1,26 @@
"use client"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list";
export type AdminOrganizationsTableProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationsTable = (props: AdminOrganizationsTableProps) => {
const {organizations} = props;
return (
<div className="flex flex-col gap-y-4 h-full py-4 ">
<Card className="h-full">
<CardHeader>
<CardTitle>Active organizations</CardTitle>
<CardDescription>Manage all system organizations</CardDescription>
</CardHeader>
<CardContent className="h-full">
<AdminOrganizationList organizations={organizations} />
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,115 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {MultiSelect} from "@/components/common/multi-select";
import {toast} from "sonner";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
ChannelsOrganizationSchema, ChannelsOrganizationType
} from "@/features/organizations/schemas/channels-organization.schema";
import {
updateNotificationChannelsOrganizationAction, updateStorageChannelsOrganizationAction
} from "@/features/organizations/actions/channels-organization.action";
import {ChannelKind} from "@/features/channel/components/channels-helpers";
type ChannelOrganisationFormProps = {
organizations?: OrganizationWithMembers[];
defaultValues?: NotificationChannelWith | StorageChannelWith
kind: ChannelKind
};
export const ChannelOrganisationForm = ({
organizations,
defaultValues,
kind
}: ChannelOrganisationFormProps) => {
const router = useRouter();
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
const form = useZodForm({
schema: ChannelsOrganizationSchema,
// @ts-expect-error — actionError not exposed in return type
defaultValues: {
organizations: defaultOrganizationIds
},
});
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
return organizations
.map((organization) => ({
value: organization.id,
label: `${organization.name}`,
}));
};
const mutationUpdateChannelOrganizations = useMutation({
mutationFn: async (values: ChannelsOrganizationType) => {
const payload = {
data: values.organizations,
id: defaultValues?.id ?? ""
};
const result = kind === "notification" ? await updateNotificationChannelsOrganizationAction(payload) : await updateStorageChannelsOrganizationAction(payload)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
}
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationUpdateChannelOrganizations.mutateAsync(values);
}}
>
<FormField
control={form.control}
name={`organizations`}
render={({field}) => (
<FormItem>
<FormControl>
<MultiSelect
options={formatOrganizationsList(organizations ?? [])}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select organization(s)"
variant="inverted"
animation={0}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutationUpdateChannelOrganizations.isPending}>
Save
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -0,0 +1,99 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { MemberWithUser } from "@/db/schema/03_organization";
import { useState } from "react";
import { authClient, useSession } from "@/lib/auth/auth-client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {updateMemberRoleAction} from "@/features/organizations/actions/update-member.action";
import {RoleSchemaMember} from "@/features/organizations/schemas/member.schema";
export const organizationMemberColumns: ColumnDef<MemberWithUser>[] = [
{
accessorKey: "role",
header: "Role",
cell: ({ row }) => {
const [role, setRole] = useState<string>(row.getValue("role"));
const { data: session } = useSession();
const activeOrgaMember = authClient.useActiveMember();
const updateMutation = useMutation({
mutationFn: () =>
updateMemberRoleAction({
memberId: row.original.id,
organizationId: row.original.organizationId,
role: RoleSchemaMember.parse(role),
}),
onSuccess: () => {
toast.success("User updated successfully.");
},
onError: () => {
toast.error("An error occurred while updating user information.");
},
});
// Only allow cycling between admin <-> member
const handleUpdateRole = async () => {
const nextRole = role === "admin" ? "member" : "admin";
setRole(nextRole);
await updateMutation.mutateAsync();
};
const isCurrentUser = session?.user.email === row.original.user.email;
const isMember = activeOrgaMember.data?.role === "member";
const isRowRoleOwner = role === "owner";
const isDisabled = isMember || isCurrentUser || isRowRoleOwner;
// Dynamic tooltip reason
const disabledReason = isCurrentUser
? "You cannot change your own role"
: isRowRoleOwner
? "Owner role cannot be modified"
: "Members cannot edit roles";
const badge = (
<Badge
className={
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-accent"
}
onClick={isDisabled ? undefined : handleUpdateRole}
variant="outline"
>
{role}
</Badge>
);
return isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent>
<p>{disabledReason}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
badge
);
},
},
{
accessorKey: "user.name",
header: "Name",
},
{
accessorKey: "user.email",
header: "Email",
},
];
@@ -0,0 +1,87 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {
AddMemberSchema,
AddMemberSchemaType
} from "@/features/organizations/schemas/admin-organization.schema";
import {SearchInput} from "@/components/ui/search-input";
import {
addMemberOrganizationAction
} from "@/features/organizations/actions/add-member.action";
import {toast} from "sonner";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
type OrganizationAddMemberFormProps = {
onSuccessAction?: () => void;
users: User[];
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}: OrganizationAddMemberFormProps) => {
const organizationMemberUserIds = organization.members.map((member) => member.user.id);
const filteredUsers = users
.filter((user) => !organizationMemberUserIds.includes(user.id))
.map((user) => ({value: user.id, label: `${user.name} | ${user.email}`}));
const router = useRouter();
const form = useZodForm({schema: AddMemberSchema});
const mutationAddMemberOrganisation = useMutation({
mutationFn: async (data: AddMemberSchemaType) => {
const result = await addMemberOrganizationAction({
userId: data.userId,
organizationId: organization.id,
role: "member",
});
toast.success("Member successfully added!");
router.refresh();
onSuccessAction?.();
},
onError: (error) => {
toast.error(error.message);
onSuccessAction?.();
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationAddMemberOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="userId"
render={({field}) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<SearchInput
name="userId"
placeholder="Enter a user email"
entries={filteredUsers}
onSelect={(entySelected: any) => {
field.onChange(entySelected.value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading
isPending={mutationAddMemberOrganisation.isPending}>Confirm</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,45 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {
OrganizationAddMemberForm
} from "@/features/organizations/components/organization-add-member-form";
import {useState} from "react";
import {UserPlus} from "lucide-react";
import {Button} from "@/components/ui/button";
import { OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
type OrganizationAddMemberModalProps = {
users: User[];
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationAddMemberModal = ({users, organization}: OrganizationAddMemberModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="w-4 h-4 mr-2"/>
Add member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add member to your organization</DialogTitle>
<DialogDescription>Select a user to add to your organization</DialogDescription>
</DialogHeader>
<OrganizationAddMemberForm users={users} organization={organization}
onSuccessAction={() => setOpen(!open)}/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,38 @@
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {cn} from "@/lib/utils";
import {Agent} from "@/db/schema/08_agent";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentCard} from "@/features/agents/components/agent-card";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
export type OrganizationAgentsTabProps = {
organization: OrganizationWithMembers;
agents: Agent[];
};
export const OrganizationAgentsTab = ({
organization,
agents,
}: OrganizationAgentsTabProps) => {
const hasAgent = agents.length > 0;
return (
<div className="flex flex-col gap-y-6 h-full py-4">
<div className="h-full flex flex-col gap-y-6">
<div className={cn("hidden flex-row justify-between items-start", hasAgent && "flex")}>
<div className="max-w-2xl ">
<h3 className="text-xl font-semibold text-balance mb-1">
Agent Settings
</h3>
</div>
<AgentDialog organization={organization} typeTrigger="create"/>
</div>
{hasAgent ? (
<CardsWithPagination data={agents} organizationView={true} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : (
<AgentDialog organization={organization} typeTrigger="empty"/>
)}
</div>
</div>
);
};
@@ -0,0 +1,27 @@
"use client"
import {ColumnDef} from "@tanstack/react-table";
import {Organization} from "@/db/schema/03_organization";
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
{
accessorKey: "name",
header: "Name",
},
// {
// header: "Action",
// id: "actions",
// cell: ({row}) => {
// const router = useRouter();
// const {data: session, isPending} = useSession();
// const isSuperAdmin = session?.user.role == "superadmin";
//
// return (
// <ButtonDeleteUser
// disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
// userId={row.original.id}/>
// );
// },
// },
];
@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import {Building2, Check, ChevronsUpDown, Plus} from "lucide-react"
import {useRouter} from "next/navigation"
import {authClient} from "@/lib/auth/auth-client"
import {cn} from "@/lib/utils"
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenuButton, useSidebar,
} from "@/components/ui/sidebar"
import {CreateOrganizationModal} from "@/features/organizations/components/organization-create-modal"
import {Skeleton} from "@/components/ui/skeleton"
export function OrganizationCombobox() {
const router = useRouter()
const {state} = useSidebar()
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
const {
data: activeOrganization,
isPending: isPendingActive,
refetch: refetchActiveOrga
} = authClient.useActiveOrganization()
const [openModal, setOpenModal] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const handleSelect = async (slug: string) => {
if (activeOrganization?.slug === slug) return
await authClient.organization.setActive({organizationSlug: slug})
router.refresh()
setIsOpen(false)
}
const handleReload = () => {
refetch();
refetchActiveOrga();
router.refresh();
};
const orgs = organizations || []
if (isPendingList || isPendingActive) {
return (
<SidebarMenuButton size="lg" className="pointer-events-none">
<Skeleton className="size-8 rounded-lg shrink-0"/>
<div className="flex flex-col gap-1.5 flex-1 overflow-hidden">
<Skeleton className="h-4 w-24"/>
<Skeleton className="h-3 w-16"/>
</div>
<Skeleton className="size-4 shrink-0"/>
</SidebarMenuButton>
)
}
return (
<>
<CreateOrganizationModal
open={openModal}
onSuccess={handleReload}
onOpenChange={setOpenModal}
/>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen} >
<DropdownMenuTrigger data-testid="organization-dropdown" asChild>
<SidebarMenuButton
size="lg"
className={cn(
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground transition-all duration-200 ease-in-out",
state === "collapsed" && "justify-center p-0"
)}
>
<div className={cn(
"flex aspect-square size-8 items-center justify-center rounded-lg text-white shadow-sm transition-transform duration-200",
activeOrganization?.logo ? "bg-transparent" : "bg-primary",
isOpen && "scale-105"
)}>
{activeOrganization?.logo ? (
<img src={activeOrganization.logo} alt={activeOrganization.name}
className="size-8 rounded-lg object-cover"/>
) : (
<Building2 className="size-5"/>
)}
</div>
<div
className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold tracking-tight">
{activeOrganization?.name || "Select Organization"}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4 opacity-50 group-data-[collapsible=icon]:hidden"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none"
align="start"
side="bottom"
sideOffset={4}
>
{orgs.map((org) => {
const isActive = activeOrganization?.id === org.id;
return (
<DropdownMenuItem
key={org.id}
onClick={() => handleSelect(org.slug)}
className={cn(
"group gap-2 p-1 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
isActive
? "bg-primary/10 text-primary border border-primary/20"
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
)}>
<div className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
org.logo ? "bg-transparent border-transparent" : "",
isActive && !org.logo ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border"
)}>
{org.logo ? (
<img src={org.logo} alt={org.name} className="size-9 rounded-md object-cover"/>
) : (
<Building2 className={cn(
"size-5",
isActive ? "text-primary-foreground" : "text-muted-foreground"
)}/>
)}
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className={cn(
"text-sm max-w-42.5 truncate font-medium leading-none",
isActive ? "text-primary" : ""
)}>{org.name}</span>
</div>
{isActive && (
<div
className="ml-auto flex size-5 items-center justify-center rounded-full bg-primary shadow-sm">
<Check className="size-3 text-primary-foreground" strokeWidth={3}/>
</div>
)}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator className="my-2 bg-border/50"/>
<DropdownMenuItem
className="gap-3 p-2.5 cursor-pointer text-muted-foreground focus:text-foreground group rounded-lg"
onClick={() => setOpenModal(true)}
>
<div
className="flex size-9 items-center justify-center rounded-md border border-dashed border-muted-foreground/30 bg-background transition-colors group-hover:border-primary/50 group-hover:bg-primary/5">
<Plus className="size-4"/>
</div>
<div className="font-medium">Create organization</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
@@ -0,0 +1,102 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {CreateOrganizationSchema, CreateOrganizationType} from "@/features/organizations/schemas/organization.schema";
import {createOrganizationAction} from "@/features/organizations/actions/organization.action";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
export type createOrganizationModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
redirect?: string;
};
export function CreateOrganizationModal({
open,
onOpenChange,
onSuccess,
redirect = "/dashboard/home"
}: createOrganizationModalProps) {
const router = useRouter();
const form = useZodForm({
schema: CreateOrganizationSchema,
});
const mutation = useMutation({
mutationFn: async (values: CreateOrganizationType) => {
const result = await createOrganizationAction(values);
if (result?.data?.success && result.data.value) {
onOpenChange(false);
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
onSuccess?.();
toast.success(result.data.actionSuccess?.message);
form.reset()
router.replace(redirect);
} else {
// @ts-expect-error — actionError not exposed in return type
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to create the organization.";
toast.error(errorMsg);
}
},
});
return (
<Dialog open={open} onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}>
<DialogContent className="sm:max-w-[425px] w-full">
<DialogHeader>
<DialogTitle>Create a new organization</DialogTitle>
</DialogHeader>
<div className="sm:max-w-[375px] w-full">
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<Button type="submit">Create</Button>
</div>
</DialogFooter>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,78 @@
"use client";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {deleteOrganizationAction} from "@/features/organizations/actions/organization.action";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {authClient} from "@/lib/auth/auth-client";
import {Trash2} from "lucide-react";
import {useIsMobile} from "@/hooks/use-mobile";
export type DeleteOrganizationButtonProps = {
organizationSlug: string;
disabled?: boolean
};
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
const router = useRouter();
const isMobile = useIsMobile();
const {refetch} = authClient.useListOrganizations();
const mutation = useMutation({
mutationFn: () => deleteOrganizationAction({slug: props.organizationSlug}),
onSuccess: async (result) => {
if (result?.data?.success) {
await authClient.organization.setActive({
organizationSlug: "default",
});
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
router.refresh()
refetch()
router.push("/");
} else {
// @ts-expect-error — actionError not exposed in return type
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
toast.error(errorMsg);
}
},
onError: (_e: any) => {
toast.error(_e?.message || "A network error occurred.");
},
});
return (
<ButtonWithConfirm
title="Delete Organization"
description="Are you sure you want to remove this organization? This action cannot be undone."
button={{
main: {
text: !isMobile ? "Delete Organization" : "",
variant: "outline",
disabled: props.disabled,
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate();
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
isPending={mutation.isPending}
/>
);
};
@@ -0,0 +1,65 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import { useRouter } from "next/navigation";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { authClient } from "@/lib/auth/auth-client";
import { toast } from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization";
type OrganizationDeleteMemberModalProps = {
open: boolean;
member: MemberWithUser;
onOpenChangeAction: (open: boolean) => void;
};
export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction }: OrganizationDeleteMemberModalProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: async () => {
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
organizationId: member.organizationId,
},
{
onSuccess: async (response) => {
toast.success("Member successfully deleted!");
onOpenChangeAction(false);
router.refresh();
},
onError: async (error) => {
toast.error("An error occurred while deleting member!");
onOpenChangeAction(false);
},
}
);
},
});
return (
<AlertDialog open={open} onOpenChange={onOpenChangeAction}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete {member.user.name } ?</AlertDialogTitle>
<AlertDialogDescription>This action is irreversible: it will permanently delete this members data.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>Validate</ButtonWithLoading>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,57 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {OrganizationForm} from "@/features/organizations/components/organization-form";
import {useState} from "react";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
import {useRouter} from "next/navigation";
type EditOrganizationDialogProps = {
organization: OrganizationWithMembers;
users: User[];
currentUser: User;
};
export const EditOrganizationDialog = ({
organization,
users,
currentUser,
}: EditOrganizationDialogProps) => {
const [open, setOpen] = useState(false);
const router = useRouter();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<div className={buttonVariants({variant: "outline", className: "cursor-pointer"})}>
<GearIcon className="w-7 h-7"/>
</div>
</DialogTrigger>
<DialogContent
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Edit {organization.name}</DialogTitle>
</DialogHeader>
<OrganizationForm
onSuccess={() => {
setOpen(false)
router.refresh()
}}
defaultValues={organization}
users={users}
currentUser={currentUser}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,170 @@
"use client";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {MultiSelect} from "@/components/common/multi-select";
import {
UpdateOrganizationSchema,
UpdateOrganizationType
} from "@/features/organizations/schemas/organization.schema";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {
updateOrganizationAction
} from "@/features/organizations/actions/organization.action";
import {toast} from "sonner";
import {User} from "@/db/schema/02_user";
import {authClient} from "@/lib/auth/auth-client";
export type organizationFormProps = {
defaultValues?: OrganizationWithMembers;
users: User[];
currentUser: User;
onSuccess?: (data: any) => void;
};
export const OrganizationForm = (props: organizationFormProps) => {
const {refetch: refetchActiveOrga} = authClient.useActiveOrganization();
const {refetch} = authClient.useListOrganizations();
const router = useRouter();
const isCreate = !Boolean(props.defaultValues);
const formatUsersList = (users: User[]) => {
return users
.filter((user) => user.id !== props.currentUser.id)
.map((user) => ({
value: user.id,
label: `${user.name} | ${user.email}`,
}));
};
const formatDefaultUsers = (members: MemberWithUser[]): string[] => {
return members
.filter((member) => member.userId !== props.currentUser.id)
.map((member) => member.userId);
};
const formattedDefaultValues = {
name: props.defaultValues?.name,
slug: props.defaultValues?.slug,
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members as MemberWithUser[]) : [],
};
const form = useZodForm({
schema: UpdateOrganizationSchema,
defaultValues: formattedDefaultValues,
});
const mutation = useMutation({
mutationFn: (values: UpdateOrganizationType) => updateOrganizationAction({
data: values,
organizationId: props.defaultValues?.id ?? ""
}),
onSuccess: async (result) => {
if (result?.data?.success) {
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
refetch()
refetchActiveOrga()
if (props.onSuccess) {
props.onSuccess(result.data.value);
} else {
router.push("/dashboard/settings");
}
} else {
const actionError = result?.data && !result.data.success ? result.data.actionError : undefined;
const errorMsg = actionError?.message || (actionError?.messageParams?.message as string | undefined) || "Failed to update the organization.";
toast.error(errorMsg);
}
},
onError: (_e: any) => {
toast.error(_e?.message || "A network error occurred.");
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Organization 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1"
{...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="users"
render={({field}) => (
<FormItem>
<FormLabel>Users</FormLabel>
<FormControl>
<MultiSelect
options={formatUsersList(props.users)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select users"
variant="inverted"
animation={2}
// maxCount={100}
/>
</FormControl>
<FormDescription>Select users you want to add to this organization</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">
{isCreate ? "Create" : "Update"}
</Button>
</div>
</Form>
);
};
@@ -0,0 +1,96 @@
"use client";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {Badge} from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {Button} from "@/components/ui/button";
import {MoreHorizontal, Settings, Trash2} from "lucide-react";
import {
OrganizationDeleteMemberModal
} from "@/features/organizations/components/organization-delete-member-modal";
import {useState} from "react";
import {authClient} from "@/lib/auth/auth-client";
import {
OrganizationMemberChangeRoleModal
} from "@/features/organizations/components/organization-member-change-role";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
type OrganizationMemberCardProps = {
member: MemberWithUser;
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationMemberCard = ({member, organization}: OrganizationMemberCardProps) => {
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
const {data: session, isPending, error} = authClient.useSession();
if (isPending || error) return null;
const isCurrentUser = session?.user?.id === member.user.id;
const isOwner = member?.role === "owner";
return (
<div key={member.id}
className="flex flex-col md:flex-row md:items-center justify-between p-4 border rounded-lg">
<OrganizationDeleteMemberModal member={member} open={isModalDeleteOpen}
onOpenChangeAction={setIsModalDeleteOpen}/>
<OrganizationMemberChangeRoleModal member={member} open={isModalRoleOpen}
onOpenChangeAction={setIsModalRoleOpen}/>
<div className="flex items-center space-x-4">
<Avatar>
<AvatarImage src={member.user.image || ""} alt={member.user.name}/>
<AvatarFallback>
{member.user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{member.user.name}</div>
<div className="text-sm text-muted-foreground">{member.user.email}</div>
<div
className="text-xs text-muted-foreground">Joined {new Date(member.createdAt).toLocaleDateString()}</div>
</div>
</div>
<div className="flex items-center space-x-2 mt-4 md:mt-0">
<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="w-4 h-4"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setIsModalRoleOpen(true)}>
<Settings className="w-4 h-4 mr-2"/>
Change role
</DropdownMenuItem>
<DropdownMenuSeparator/>
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2"/>
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
const getRoleBadgeVariant = (role: string) => {
switch (role.toLowerCase()) {
case "owner":
return "default";
case "admin":
return "secondary";
default:
return "outline";
}
};
@@ -0,0 +1,96 @@
"use client";
import {useState} from "react";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import {MemberRoleType} from "@/types/common";
import {MemberWithUser} from "@/db/schema/03_organization";
import {
updateMemberRoleAdminAction
} from "@/features/organizations/actions/role-member.action";
import {RoleSchemaMember} from "@/features/organizations/schemas/member.schema";
type OrganizationMemberChangeRoleModalProps = {
open: boolean;
member: MemberWithUser;
onOpenChangeAction: (open: boolean) => void;
};
export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChangeRoleModalProps) => {
const {member, open, onOpenChangeAction} = props;
const router = useRouter();
const [role, setRole] = useState<MemberRoleType>(member.role as MemberRoleType);
const mutation = useMutation({
mutationFn: () =>
updateMemberRoleAdminAction({
memberId: member.id,
organizationId: member.organizationId,
role: RoleSchemaMember.parse(role),
}),
onSuccess: () => {
toast.success("Member successfully updated");
onOpenChangeAction(false);
router.refresh();
},
onError: (error) => {
toast.error("An error occurred while updating member");
onOpenChangeAction(false);
},
});
return (
<Dialog open={open} onOpenChange={onOpenChangeAction}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change the users role</DialogTitle>
<DialogDescription>Modify the role of this user within your organization.</DialogDescription>
</DialogHeader>
<Select defaultValue={member.role ?? ""} onValueChange={(role) => setRole(role as MemberRoleType)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sélectionnez un rôle"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
</SelectContent>
</Select>
<DialogFooter>
<div className="flex gap-4 justify-end">
<ButtonWithLoading
variant="outline"
onClick={() => {
onOpenChangeAction(false);
}}
>
Cancel
</ButtonWithLoading>
<ButtonWithLoading
isPending={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync();
}}
>
Validate
</ButtonWithLoading>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,22 @@
import {DataTable} from "@/components/common/data-table";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {
organizationMemberColumns
} from "@/features/organizations/components/member-columns";
interface SettingsOrganizationMembersTableProps {
organization: OrganizationWithMembers
}
export const SettingsOrganizationMembersTable = ({organization}: SettingsOrganizationMembersTableProps) => {
return (
<div className="flex flex-col h-full ">
<div className=" h-full">
<DataTable
columns={organizationMemberColumns}
enableSelect={false}
data={organization.members as MemberWithUser[]}/>
</div>
</div>
)
}
@@ -0,0 +1,60 @@
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {useState} from "react";
import {cn} from "@/lib/utils";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import {ChannelCard} from "@/features/channel/components/channel-card";
export type OrganizationNotifiersTabProps = {
organization: OrganizationWithMembers;
notificationChannels: NotificationChannel[];
};
export const OrganizationNotifiersTab = ({
organization,
notificationChannels,
}: OrganizationNotifiersTabProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const hasNotifiers = notificationChannels.length > 0;
const kind="notification"
return (
<div className="flex flex-col gap-y-6 h-full py-4">
<div className="h-full flex flex-col gap-y-6">
<div className={cn("hidden flex-row justify-between items-start", hasNotifiers && "flex")}>
<div className="max-w-2xl ">
<h3 className="text-xl font-semibold text-balance mb-1">
Notification Settings
</h3>
</div>
<ChannelAddEditModal
kind={kind}
organization={organization}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
/>
</div>
{hasNotifiers ? (
<div className="h-full">
<CardsWithPagination
data={notificationChannels}
cardItem={ChannelCard}
cardsPerPage={8}
numberOfColumns={2}
organization={organization}
kind={kind}
/>
</div>
) : (
<EmptyStatePlaceholder
text="No notification channels configured yet"
onClick={() => setIsAddModalOpen(true)}
className="h-full"
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,60 @@
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {useState} from "react";
import {cn} from "@/lib/utils";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import {ChannelCard} from "@/features/channel/components/channel-card";
import {StorageChannel} from "@/db/schema/12_storage-channel";
export type OrganizationNotifiersTabProps = {
organization: OrganizationWithMembers;
storageChannels: StorageChannel[];
};
export const OrganizationStoragesTab = ({
organization,
storageChannels,
}: OrganizationNotifiersTabProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const hasNotifiers = storageChannels.length > 0;
const kind = "storage"
return (
<div className="flex flex-col gap-y-6 h-full py-4">
<div className="h-full flex flex-col gap-y-6">
<div className={cn("hidden flex-row justify-between items-start", hasNotifiers && "flex")}>
<div className="max-w-2xl ">
<h3 className="text-xl font-semibold text-balance mb-1">
Notification Settings
</h3>
</div>
<ChannelAddEditModal
kind={kind}
organization={organization}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
/>
</div>
{hasNotifiers ? (
<div className="h-full">
<CardsWithPagination
data={storageChannels}
cardItem={ChannelCard}
cardsPerPage={8}
numberOfColumns={2}
organization={organization}
kind={kind}
/>
</div>
) : (
<EmptyStatePlaceholder
text="No storage channels configured yet"
onClick={() => setIsAddModalOpen(true)}
className="h-full"
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,122 @@
"use client";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {useOrganizationPermissions} from "@/features/organizations/hooks/use-organization-permissions";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {
SettingsOrganizationMembersTable
} from "@/features/organizations/components/organization-members-table";
import {
OrganizationNotifiersTab
} from "@/features/organizations/components/organization-notifiers-tab";
import {
OrganizationStoragesTab
} from "@/features/organizations/components/organization-storages-tab";
import {
OrganizationAgentsTab
} from "@/features/organizations/components/organization-agents-tab";
import {Agent} from "@/db/schema/08_agent";
export type OrganizationTabsProps = {
organization: OrganizationWithMembers;
notificationChannels: NotificationChannel[];
storageChannels: StorageChannel[];
activeMember: MemberWithUser;
agents: Agent[]
};
export const OrganizationTabs = ({
activeMember,
organization,
notificationChannels,
storageChannels,
agents
}: OrganizationTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
const {
canManageNotifications,
canManageStorages
} = useOrganizationPermissions(activeMember);
useEffect(() => {
const newTab = searchParams.get("tab") ?? "users";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return (
<div className="h-full">
{(canManageNotifications && canManageStorages) ?
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
<TabsList className="w-full">
<TabsTrigger
className="w-full"
value="users"
>
Users
</TabsTrigger>
<TabsTrigger
className="w-full"
value="notifications"
>
Notifiers
</TabsTrigger>
<TabsTrigger
className="w-full"
value="storages"
>
Storages
</TabsTrigger>
<TabsTrigger
className="w-full"
value="agents"
>
Agents
</TabsTrigger>
</TabsList>
<TabsContent className="h-full" value="users">
<SettingsOrganizationMembersTable organization={organization}/>
</TabsContent>
<TabsContent className="h-full" value="notifications">
<OrganizationNotifiersTab
organization={organization}
notificationChannels={notificationChannels}
/>
</TabsContent>
<TabsContent className="h-full" value="storages">
<OrganizationStoragesTab
organization={organization}
storageChannels={storageChannels}
/>
</TabsContent>
<TabsContent className="h-full" value="agents">
<OrganizationAgentsTab
organization={organization}
agents={agents}
/>
</TabsContent>
</Tabs>
:
<SettingsOrganizationMembersTable organization={organization}/>
}
</div>
);
};
@@ -0,0 +1,90 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {
UpdateOrganizationSchema,
UpdateOrganizationSchemaType
} from "@/features/organizations/schemas/admin-organization.schema";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import {Input} from "@/components/ui/input";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {updateOrganizationAction} from "@/features/organizations/actions/organization.action";
type UpdateOrganizationFormProps = {
onSuccessAction?: () => void;
defaultValues: OrganizationWithMembersAndUsers;
};
export const UpdateOrganizationForm = ({onSuccessAction, defaultValues}: UpdateOrganizationFormProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const isDefaultOrganization = defaultValues.slug == "default";
const form = useZodForm({
schema: UpdateOrganizationSchema,
defaultValues: defaultValues,
disabled: isDefaultOrganization,
});
const mutationUpdateOrganisation = useMutation({
mutationFn: ({name}: UpdateOrganizationSchemaType) => updateOrganizationAction({
data: {
name: name,
users: [],
slug: defaultValues.slug
},
organizationId: defaultValues.id,
}),
onSuccess: async (result) => {
if (result?.data?.success) {
toast.success("Organization updated successfully.");
router.refresh();
refetch()
} else {
// @ts-expect-error — actionError not exposed in return type
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
toast.error(errorMsg);
}
},
onError: (_e: any) => {
toast.error(_e?.message || "A network error occurred.");
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationUpdateOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="" {...field} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading disabled={isDefaultOrganization} isPending={mutationUpdateOrganisation.isPending}>Validate</ButtonWithLoading>
</div>
</Form>
);
};