Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Lock } from "lucide-react";
import { Card } from "@onecli/ui/components/card";

/**
* Rendered when the members query 403s — the API is the authority on who is
* an admin (D-K). A plain card: no retry, no toast (the 403 is deterministic).
*/
export const AdminOnlyNotice = () => (
<Card className="flex flex-col items-center justify-center py-16 text-center">
<div className="bg-muted mb-4 flex size-12 items-center justify-center rounded-full">
<Lock className="text-muted-foreground size-6" />
</div>
<p className="text-sm font-medium">Admins only</p>
<p className="text-muted-foreground mt-1 max-w-xs text-xs">
Managing members and invitations requires an organization admin. Ask an
admin if you need someone added to the team.
</p>
</Card>
);
32 changes: 32 additions & 0 deletions apps/web/src/app/(dashboard)/team/_components/copy-link-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { Copy, Check } from "lucide-react";
import { Button } from "@onecli/ui/components/button";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";

export interface CopyLinkButtonProps {
token: string;
}

/**
* Icon-only copy action for a pending invitation row — composes the join
* link from the browser's own origin (D-I).
*/
export const CopyLinkButton = ({ token }: CopyLinkButtonProps) => {
const { copied, copy } = useCopyToClipboard();
return (
<Button
variant="ghost"
size="icon"
className="size-8"
title="Copy invite link"
onClick={() => copy(`${window.location.origin}/join/${token}`)}
>
{copied ? (
<Check className="size-4 text-brand" />
) : (
<Copy className="size-4" />
)}
</Button>
);
};
153 changes: 153 additions & 0 deletions apps/web/src/app/(dashboard)/team/_components/invite-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"use client";

import { useState } from "react";
import { CircleCheck } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@onecli/ui/components/dialog";
import { Button } from "@onecli/ui/components/button";
import { Input } from "@onecli/ui/components/input";
import { Label } from "@onecli/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@onecli/ui/components/select";
import { useCreateInvitation } from "@/hooks/use-invitations";
import type { InvitationRow } from "@/lib/api";
import { InviteLinkField } from "./invite-link-field";

export interface InviteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export const InviteDialog = ({ open, onOpenChange }: InviteDialogProps) => {
const [email, setEmail] = useState("");
const [role, setRole] = useState<"admin" | "member">("member");
const [created, setCreated] = useState<InvitationRow | null>(null);
const createInvitation = useCreateInvitation();

const trimmedEmail = email.trim();
const isEmailPlausible = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail);

const handleCreate = () => {
if (!isEmailPlausible || createInvitation.isPending) return;
createInvitation.mutate(
{ email: trimmedEmail, role },
{ onSuccess: (invitation) => setCreated(invitation) },
);
};

const handleClose = (value: boolean) => {
if (!value) {
setEmail("");
setRole("member");
setCreated(null);
}
onOpenChange(value);
};

// D-I: the link is composed from the browser's own origin — the server
// never guesses a public URL.
const joinLink = created
? `${window.location.origin}/join/${created.token}`
: "";

return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
{created ? (
<>
<div className="flex flex-col items-center pt-2 text-center">
<div className="bg-brand/10 mb-3 flex size-10 items-center justify-center rounded-full">
<CircleCheck className="size-5 text-brand" />
</div>
<DialogHeader className="items-center">
<DialogTitle>Invitation created</DialogTitle>
<DialogDescription>
OneCLI open edition doesn&apos;t send email. Copy this link
and send it to <strong>{created.email}</strong> yourself. They
must sign in with that exact Google address, and the link
expires in 7 days.
</DialogDescription>
</DialogHeader>
</div>
<div className="py-2">
<InviteLinkField link={joinLink} />
</div>
<DialogFooter>
<Button onClick={() => handleClose(false)} className="w-full">
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Invite a member</DialogTitle>
<DialogDescription>
Create an invitation link for a teammate. They must sign in with
the exact Google address you enter here.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-2">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
placeholder="teammate@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">Role</Label>
<Select
value={role}
onValueChange={(value) =>
setRole(value === "admin" ? "admin" : "member")
}
>
<SelectTrigger id="invite-role" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button
onClick={handleCreate}
loading={createInvitation.isPending}
disabled={!isEmailPlausible || createInvitation.isPending}
>
{createInvitation.isPending
? "Creating..."
: "Create invitation"}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { Copy, Check } from "lucide-react";
import { Button } from "@onecli/ui/components/button";
import { Input } from "@onecli/ui/components/input";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";

export interface InviteLinkFieldProps {
link: string;
}

/** Read-only join link + copy button (the api-key-card pattern). */
export const InviteLinkField = ({ link }: InviteLinkFieldProps) => {
const { copied, copy } = useCopyToClipboard();

return (
<div className="flex items-center gap-2">
<Input
readOnly
value={link}
className="font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
size="icon"
className="shrink-0"
title="Copy invite link"
onClick={() => copy(link)}
>
{copied ? (
<Check className="size-4 text-brand" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Users } from "lucide-react";
import { Card } from "@onecli/ui/components/card";

/** Local auth mode has exactly one identity — the team surface is inert. */
export const LocalModeNotice = () => (
<Card className="flex flex-col items-center justify-center py-16 text-center">
<div className="bg-muted mb-4 flex size-12 items-center justify-center rounded-full">
<Users className="text-muted-foreground size-6" />
</div>
<p className="text-sm font-medium">Team is unavailable in local mode</p>
<p className="text-muted-foreground mt-1 max-w-md text-xs">
This instance runs in local auth mode, which has exactly one built-in
identity (admin@localhost). To invite teammates, configure Google OAuth
(NEXTAUTH_SECRET + GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) and restart.
</p>
</Card>
);
Loading