diff --git a/apps/examples/calcom/app/(dashboard)/booking/past/booking-actions.tsx b/apps/examples/calcom/app/(dashboard)/booking/past/booking-actions.tsx index 7b4036207..1cdb0d239 100644 --- a/apps/examples/calcom/app/(dashboard)/booking/past/booking-actions.tsx +++ b/apps/examples/calcom/app/(dashboard)/booking/past/booking-actions.tsx @@ -36,7 +36,7 @@ export function BookingActions() { render={ + } diff --git a/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx b/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx index 266c9955c..798e05258 100644 --- a/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx +++ b/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx @@ -43,8 +43,8 @@ import { ListItemContent, ListItemDescription, ListItemHeader, + ListItemSpanningTrigger, ListItemTitle, - ListItemTitleLink, } from "@/components/list-item"; import { useLoadingState } from "@/hooks/use-loading-state"; import { @@ -115,9 +115,9 @@ export function BookingsList() { - + }> {booking.title} - + {participants} diff --git a/apps/examples/calcom/app/(dashboard)/event-types/add-event-type-dialog.tsx b/apps/examples/calcom/app/(dashboard)/event-types/add-event-type-dialog.tsx index 96affe5e2..03d867eab 100644 --- a/apps/examples/calcom/app/(dashboard)/event-types/add-event-type-dialog.tsx +++ b/apps/examples/calcom/app/(dashboard)/event-types/add-event-type-dialog.tsx @@ -40,7 +40,7 @@ export function AddEventTypeDialog({ }> {children} - +
Add a new event type @@ -48,7 +48,7 @@ export function AddEventTypeDialog({ Set up event types to offer different types of meetings. - + Title diff --git a/apps/examples/calcom/app/(dashboard)/event-types/event-types-list.tsx b/apps/examples/calcom/app/(dashboard)/event-types/event-types-list.tsx index 125692411..7df0b47b2 100644 --- a/apps/examples/calcom/app/(dashboard)/event-types/event-types-list.tsx +++ b/apps/examples/calcom/app/(dashboard)/event-types/event-types-list.tsx @@ -20,6 +20,7 @@ import { ShuffleIcon, UsersIcon, } from "lucide-react"; +import Link from "next/link"; import { useRef, useState } from "react"; import { ListItem, @@ -28,8 +29,8 @@ import { ListItemDescription, ListItemDragHandle, ListItemHeader, + ListItemSpanningTrigger, ListItemTitle, - ListItemTitleLink, SortableListItem, sortableListClasses, } from "@/components/list-item"; @@ -143,9 +144,9 @@ function EventTypeItemContent({
- + }> {eventType.title} - + {eventPath} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/apps/analytics/analytics-apps-content.tsx b/apps/examples/calcom/app/(settings)/settings/admin/apps/analytics/analytics-apps-content.tsx new file mode 100644 index 000000000..f6159df57 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/apps/analytics/analytics-apps-content.tsx @@ -0,0 +1,545 @@ +"use client"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "@coss/ui/components/alert-dialog"; +import { Button } from "@coss/ui/components/button"; +import { Card, CardPanel } from "@coss/ui/components/card"; +import { + Dialog, + DialogClose, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@coss/ui/components/dialog"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@coss/ui/components/empty"; +import { Field, FieldLabel } from "@coss/ui/components/field"; +import { Input } from "@coss/ui/components/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@coss/ui/components/input-group"; +import { ScrollArea } from "@coss/ui/components/scroll-area"; +import { Switch } from "@coss/ui/components/switch"; +import { toastManager } from "@coss/ui/components/toast"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@coss/ui/components/tooltip"; +import { useMediaQuery } from "@coss/ui/hooks/use-media-query"; +import { + BarChart3Icon, + CalendarIcon, + CameraIcon, + CreditCardIcon, + HashIcon, + Link2Icon, + MessageSquareIcon, + SearchIcon, +} from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { + AppHeader, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; +import { + ListItem, + ListItemActions, + ListItemContent, + ListItemDescription, + ListItemHeader, + ListItemTitle, +} from "@/components/list-item"; + +interface AnalyticsApp { + id: string; + name: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + enabled: boolean; + configured?: boolean; + configurable?: boolean; + slug: string; +} + +const ANALYTICS_APPS: AnalyticsApp[] = [ + { + configurable: false, + description: + "Privacy-first web analytics for devs (Google Analytics alternative) — 3 KB, GDPR-compliant", + enabled: false, + icon: BarChart3Icon, + id: "databuddy", + name: "Databuddy", + slug: "databuddy", + }, + { + configurable: true, + configured: true, + description: + "Dub is the modern link attribution platform for you to create short links, track conversion analytics, and run affiliate programs.", + enabled: true, + icon: Link2Icon, + id: "dub", + name: "Dub", + slug: "dub", + }, + { + configurable: false, + description: + "Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative.", + enabled: false, + icon: BarChart3Icon, + id: "fathom", + name: "Fathom", + slug: "fathom", + }, + { + configurable: true, + description: + "Google Analytics is a web analytics service offered by Google that tracks and reports website traffic, currently as a platform inside the Google Marketing Platform brand.", + enabled: false, + icon: BarChart3Icon, + id: "google-analytics", + name: "Google Analytics", + slug: "google-analytics", + }, + { + configurable: false, + description: "App to install Google Tag Manager", + enabled: false, + icon: BarChart3Icon, + id: "google-tag-manager", + name: "Google Tag Manager", + slug: "google-tag-manager", + }, + { + configurable: false, + description: + "Insihts is an all-in-one platform for businesses looking to track user behavior, optimize workflows, and make data-driven decisions. Whether you are a marketer, product manager, or part of a customer success team, Insihts provides the tools you need to succeed.", + enabled: false, + icon: BarChart3Icon, + id: "insihts", + name: "Insihts", + slug: "insihts", + }, + { + configurable: false, + description: + "Google Analytics alternative that protects your data and your customers' privacy", + enabled: false, + icon: BarChart3Icon, + id: "matomo", + name: "Matomo", + slug: "matomo", + }, + { + configurable: true, + description: + "Add Meta Pixel to your bookings page to measure, optimize and build audiences for your ad campaigns.", + enabled: false, + icon: BarChart3Icon, + id: "meta-pixel", + name: "Meta Pixel", + slug: "meta-pixel", + }, + { + configurable: false, + description: "Simple, privacy-friendly Google Analytics", + enabled: false, + icon: BarChart3Icon, + id: "plausible", + name: "Plausible", + slug: "plausible", + }, +]; + +const APP_CATEGORIES = [ + { + href: "/settings/admin/apps/analytics", + icon: BarChart3Icon, + id: "analytics", + label: "Analytics", + }, + { + href: "/settings/admin/apps/analytics", + icon: Link2Icon, + id: "ai-automation", + label: "AI & Automation", + }, + { + href: "/settings/admin/apps/analytics", + icon: CalendarIcon, + id: "calendar", + label: "Calendar", + }, + { + href: "/settings/admin/apps/analytics", + icon: CameraIcon, + id: "conferencing", + label: "Conferencing", + }, + { + href: "/settings/admin/apps/analytics", + icon: BarChart3Icon, + id: "crm", + label: "CRM", + }, + { + href: "/settings/admin/apps/analytics", + icon: MessageSquareIcon, + id: "messaging", + label: "Messaging", + }, + { + href: "/settings/admin/apps/analytics", + icon: CreditCardIcon, + id: "payment", + label: "Payment", + }, + { + href: "/settings/admin/apps/analytics", + icon: HashIcon, + id: "other", + label: "Other", + }, +] as const; + +function AppIcon({ + icon: Icon, + className, +}: { + icon: React.ComponentType<{ className?: string }>; + className?: string; +}) { + return ( +
+ +
+ ); +} + +function AnalyticsAppRow({ + app, + onToggle, + onConfigure, +}: { + app: AnalyticsApp; + onToggle: (slug: string, checked: boolean) => void; + onConfigure: (slug: string) => void; +}) { + const Icon = app.icon; + + return ( + + +
+ + + {app.name} + + {app.description} + + {app.configurable && ( + + )} + +
+
+ + + onToggle(app.slug, checked)} + /> + } + /> + + {app.enabled ? `Disable ${app.name}` : `Enable ${app.name}`} + + + +
+ ); +} + +export function AnalyticsAppsContent() { + const isSmallScreen = useMediaQuery("max-sm"); + const pathname = usePathname(); + const router = useRouter(); + const [apps, setApps] = useState(ANALYTICS_APPS); + const [searchQuery, setSearchQuery] = useState(""); + const [disableDialogAppSlug, setDisableDialogAppSlug] = useState< + string | null + >(null); + const [editDialogAppSlug, setEditDialogAppSlug] = useState( + null, + ); + const [editKeys, setEditKeys] = useState({ + client_id: "", + client_secret: "", + }); + + const filteredApps = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return apps; + + return apps.filter( + (app) => + app.name.toLowerCase().includes(query) || + app.description.toLowerCase().includes(query), + ); + }, [apps, searchQuery]); + + function handleToggle(slug: string, checked: boolean) { + if (!checked) { + setDisableDialogAppSlug(slug); + return; + } + + setApps((prev) => + prev.map((app) => + app.slug === slug ? { ...app, enabled: checked } : app, + ), + ); + toastManager.add({ + title: "App enabled", + type: "success", + }); + } + + function handleDisableConfirm() { + if (!disableDialogAppSlug) return; + + setApps((prev) => + prev.map((app) => + app.slug === disableDialogAppSlug ? { ...app, enabled: false } : app, + ), + ); + toastManager.add({ + title: "App disabled", + type: "success", + }); + setDisableDialogAppSlug(null); + } + + function handleDisableDialogOpenChange(open: boolean) { + if (!open) setDisableDialogAppSlug(null); + } + + function handleConfigure(slug: string) { + setEditKeys({ client_id: "", client_secret: "" }); + setEditDialogAppSlug(slug); + } + + function handleEditDialogOpenChange(open: boolean) { + if (!open) setEditDialogAppSlug(null); + } + + function handleEditKeysSave() { + toastManager.add({ + title: "Keys saved", + type: "success", + }); + setEditDialogAppSlug(null); + } + + const renderCategoryButton = (category: (typeof APP_CATEGORIES)[number]) => { + const Icon = category.icon; + const isActive = pathname.endsWith("/analytics") + ? category.id === "analytics" + : pathname === category.href; + + return ( + + ); + }; + + return ( + <> + + + + Disable app + + Disabling this app could cause problems with how your users + interact with Cal + + + + }> + Cancel + + Confirm} + /> + + + + + + + + Edit keys + + + + client_id + + setEditKeys((prev) => ({ + ...prev, + client_id: e.currentTarget.value, + })) + } + type="text" + value={editKeys.client_id} + /> + + + client_secret + + setEditKeys((prev) => ({ + ...prev, + client_secret: e.currentTarget.value, + })) + } + type="text" + value={editKeys.client_secret} + /> + + + + }>Close + + + + + + + + + Enable apps for your instance of Cal + + + +
+ {isSmallScreen ? ( + +
+ {APP_CATEGORIES.map((category) => ( +
+ {renderCategoryButton(category)} +
+ ))} +
+
+ ) : ( + + )} +
+
+ + setSearchQuery(e.currentTarget.value)} + placeholder="Search apps…" + type="search" + value={searchQuery} + /> + + + +
+ + {filteredApps.length > 0 ? ( + + + {filteredApps.map((app) => ( + + ))} + + + ) : ( + + + + + No apps found + + Try a different search term or browse another category + + + + + )} +
+
+ + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/apps/analytics/page.tsx b/apps/examples/calcom/app/(settings)/settings/admin/apps/analytics/page.tsx new file mode 100644 index 000000000..b4697c84d --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/apps/analytics/page.tsx @@ -0,0 +1,5 @@ +import { AnalyticsAppsContent } from "./analytics-apps-content"; + +export default function AnalyticsAppsPage() { + return ; +} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/apps/page.tsx b/apps/examples/calcom/app/(settings)/settings/admin/apps/page.tsx new file mode 100644 index 000000000..11d5d217d --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/apps/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AppsPage() { + redirect("/settings/admin/apps/analytics"); +} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/billing/page.tsx b/apps/examples/calcom/app/(settings)/settings/admin/billing/page.tsx new file mode 100644 index 000000000..5ca6ca2a7 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/billing/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { Badge } from "@coss/ui/components/badge"; +import { Button } from "@coss/ui/components/button"; +import { + Card, + CardFrame, + CardFrameDescription, + CardFrameHeader, + CardFrameTitle, + CardPanel, +} from "@coss/ui/components/card"; +import { Field, FieldDescription, FieldLabel } from "@coss/ui/components/field"; +import { Group } from "@coss/ui/components/group"; +import { Input } from "@coss/ui/components/input"; +import { + Sheet, + SheetClose, + SheetDescription, + SheetFooter, + SheetHeader, + SheetPanel, + SheetPopup, + SheetTitle, + SheetTrigger, +} from "@coss/ui/components/sheet"; +import { ExternalLinkIcon } from "lucide-react"; +import { + AppHeader, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; + +export default function AdminBillingPage() { + return ( + <> + + + + Manage billing emails and Stripe portal access for licenses. + + + +
+ + + Self-hosted deployment lookup + + + + + Billing email + + +
+ + }>Search + + + Deployment details + a@gmail.com + + +
+

+ Billing details +

+
+
+
+ Billing email +
+
a@gmail.com
+
+
+
+ Customer ID +
+
+ cus_mock123456789 +
+
+
+
+ Created +
+
1/15/2024
+
+
+
+ Last updated +
+
3/1/2024
+
+
+
+ +
+

+ License keys (2) +

+
+
+
+
+
+ cal_live...xxxxxxxx +
+
+ active + SELF_HOSTED +
+
+
+
+ Subscription ID +
+
+ sub_mock123456789 +
+
+
+
+ Billing type +
+
PER_USER
+
+
+
+ Entity count +
+
10
+
+
+
+ Entity price +
+
$5.00
+
+
+
+ Overages +
+
0
+
+
+
+

+ Recent usage (last 30 days) +

+
+
+
+ 2/1/2024 +
+
42
+
+
+
+ 2/15/2024 +
+
38
+
+
+
+ 3/1/2024 +
+
55
+
+
+
+
+ +
+
+
+
+ cal_live...yyyyyyyy +
+
+ + Inactive + + SELF_HOSTED +
+
+
+
+ Subscription ID +
+
Not set
+
+
+
+
+
+
+ + }> + Close + + + + +
+
+
+
+ + Search for a self-hosted deployment by billing email to view + and manage its details. + +
+
+
+
+ + + Resend purchase confirmation + + + + + Billing email + + +
+ +
+
+ + Send the purchase confirmation email to a billing address. + +
+
+
+
+ + + Billing portal + + + +
+
+ + Open the Stripe billing portal for this license. + +
+ +
+
+
+
+
+ + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/flags/flag-admin-list.tsx b/apps/examples/calcom/app/(settings)/settings/admin/flags/flag-admin-list.tsx new file mode 100644 index 000000000..685953bb5 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/flags/flag-admin-list.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@coss/ui/components/avatar"; +import { Button } from "@coss/ui/components/button"; +import { + Collapsible, + CollapsiblePanel, + CollapsibleTrigger, +} from "@coss/ui/components/collapsible"; +import { Frame, FrameHeader, FramePanel } from "@coss/ui/components/frame"; +import { Input } from "@coss/ui/components/input"; +import { Label } from "@coss/ui/components/label"; +import { + Sheet, + SheetClose, + SheetDescription, + SheetFooter, + SheetHeader, + SheetPanel, + SheetPopup, + SheetTitle, +} from "@coss/ui/components/sheet"; +import { Switch } from "@coss/ui/components/switch"; +import { toastManager } from "@coss/ui/components/toast"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@coss/ui/components/tooltip"; +import { ChevronDownIcon, UsersIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + ListItem, + ListItemActions, + ListItemContent, + ListItemDescription, + ListItemHeader, + ListItemTitle, +} from "@/components/list-item"; + +interface FeatureFlag { + slug: string; + description: string; + enabled: boolean; + type: string; +} + +interface AssignableUser { + id: string; + name: string; + email: string; + avatarUrl?: string; +} + +const FEATURE_FLAGS: FeatureFlag[] = [ + { + description: "Enable calendar caching for improved performance", + enabled: true, + slug: "calendar-cache", + type: "Operations", + }, + { + description: "Serve cached calendar data to users", + enabled: true, + slug: "calendar-cache-serve", + type: "Operations", + }, + { + description: "Enable email notifications", + enabled: true, + slug: "emails", + type: "Operations", + }, + { + description: "Enable insights dashboard", + enabled: true, + slug: "insights", + type: "Operations", + }, + { + description: "Enable team functionality", + enabled: true, + slug: "teams", + type: "Operations", + }, + { + description: "Enable webhook integrations", + enabled: true, + slug: "webhooks", + type: "Operations", + }, + { + description: "Enable workflow automations", + enabled: true, + slug: "workflows", + type: "Operations", + }, + { + description: "Enable organization features", + enabled: true, + slug: "organizations", + type: "Operations", + }, + { + description: "Require email verification during sign up", + enabled: true, + slug: "email-verification", + type: "Operations", + }, + { + description: "Disable new user signups", + enabled: false, + slug: "disable-signup", + type: "Operations", + }, + { + description: "Enable Google Workspace directory integration", + enabled: false, + slug: "google-workspace-directory", + type: "Experiment", + }, + { + description: "Enable user attributes for routing", + enabled: true, + slug: "attributes", + type: "Experiment", + }, + { + description: "Use updated organizer request email template", + enabled: false, + slug: "organizer-request-email-v2", + type: "Experiment", + }, + { + description: "Enable delegation credential feature", + enabled: false, + slug: "delegation-credential", + type: "Experiment", + }, + { + description: "Enable Salesforce CRM tasker integration", + enabled: false, + slug: "salesforce-crm-tasker", + type: "Experiment", + }, + { + description: "Use SMTP for workflow emails", + enabled: false, + slug: "workflow-smtp-emails", + type: "Experiment", + }, + { + description: "Show log-in overlay on Cal Video", + enabled: false, + slug: "cal-video-log-in-overlay", + type: "Experiment", + }, + { + description: "Enable permission-based access control", + enabled: false, + slug: "pbac", + type: "Experiment", + }, + { + description: "Enable restriction schedule feature", + enabled: false, + slug: "restriction-schedule", + type: "Experiment", + }, + { + description: "Enable new bookings experience (v3)", + enabled: false, + slug: "bookings-v3", + type: "Experiment", + }, + { + description: "Enable booking audit logging", + enabled: false, + slug: "booking-audit", + type: "Experiment", + }, + { + description: "Enable sidebar tips for onboarding", + enabled: true, + slug: "sidebar-tips", + type: "Killswitch", + }, + { + description: "Enable tiered support chat", + enabled: false, + slug: "tiered-support-chat", + type: "Killswitch", + }, + { + description: "Review signups against watchlist", + enabled: false, + slug: "signup-watchlist-review", + type: "Killswitch", + }, +]; + +const USERS: AssignableUser[] = [ + { + avatarUrl: + "https://pbs.twimg.com/profile_images/1994776674391457792/7utKOMi6_400x400.jpg", + email: "pasquale@cal.com", + id: "usr_pasquale", + name: "Pasquale Vitiello", + }, + { + email: "margaret@cal.com", + id: "usr_margaret", + name: "Margaret Welsh", + }, + { + email: "brian@cal.com", + id: "usr_brian", + name: "Brian Smith", + }, + { + email: "anna@cal.com", + id: "usr_anna", + name: "Anna Taylor", + }, + { + email: "sofia@cal.com", + id: "usr_sofia", + name: "Sofia Rodriguez", + }, + { + email: "david@cal.com", + id: "usr_david", + name: "David Chen", + }, + { + email: "elena@cal.com", + id: "usr_elena", + name: "Elena Rossi", + }, + { + email: "james@cal.com", + id: "usr_james", + name: "James Lee", + }, +]; + +function groupFlagsByType(flags: FeatureFlag[]) { + const grouped: Record = {}; + + for (const flag of flags) { + const type = flag.type; + if (!grouped[type]) { + grouped[type] = []; + } + grouped[type].push(flag); + } + + return grouped; +} + +export function FlagAdminList() { + const [flags, setFlags] = useState(FEATURE_FLAGS); + const [activeFlagSlug, setActiveFlagSlug] = useState(null); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [isAssignSheetOpen, setIsAssignSheetOpen] = useState(false); + const [userQuery, setUserQuery] = useState(""); + const [visibleCount, setVisibleCount] = useState(5); + + const filteredUsers = useMemo(() => { + const normalizedQuery = userQuery.trim().toLowerCase(); + + if (!normalizedQuery) return USERS; + + return USERS.filter((user) => + [user.name, user.email].some((value) => + value.toLowerCase().includes(normalizedQuery), + ), + ); + }, [userQuery]); + + const visibleUsers = filteredUsers.slice(0, visibleCount); + const hasMore = visibleCount < filteredUsers.length; + + const groupedFlags = groupFlagsByType(flags); + const sortedTypes = Object.keys(groupedFlags).sort(); + + function handleToggle(slug: string, checked: boolean) { + setFlags((prev) => + prev.map((flag) => + flag.slug === slug ? { ...flag, enabled: checked } : flag, + ), + ); + toastManager.add({ + title: "Flags successfully updated", + type: "success", + }); + } + + function handleAssignUsersClick(slug: string) { + setActiveFlagSlug(slug); + setVisibleCount(5); + setIsAssignSheetOpen(true); + } + + function handleUserAssignedChange(userId: string, checked: boolean) { + setSelectedUserIds((prev) => { + if (checked && !prev.includes(userId)) return [...prev, userId]; + if (!checked) return prev.filter((id) => id !== userId); + return prev; + }); + } + + function handleSaveAssignments() { + toastManager.add({ + title: "Users successfully assigned", + type: "success", + }); + } + + return ( + +
+ {sortedTypes.map((type) => ( + + ))} +
+ + + + Assign to users + + {activeFlagSlug + ? `Assign ${activeFlagSlug} to one or more users.` + : "Assign this flag to one or more users."} + + + + + { + setUserQuery(e.currentTarget.value); + setVisibleCount(5); + }} + placeholder="Search users…" + value={userQuery} + /> +
+ {visibleUsers.map((user) => { + const switchId = `assign-flag-user-${user.id}`; + + return ( + + ); + })} +
+ {hasMore && ( + + )} +
+ + + }>Cancel + }> + Save + + +
+
+ ); +} + +interface FlagGroupProps { + type: string; + flags: FeatureFlag[]; + onToggle: (slug: string, checked: boolean) => void; + onAssignUsers: (slug: string) => void; +} + +function FlagGroup({ type, flags, onAssignUsers, onToggle }: FlagGroupProps) { + return ( + + + + } + > + + {type} + + + + + {flags.map((flag) => ( + + + + {flag.slug} + + {flag.description} + + + + + + + handleToggle(flag.slug, checked) + } + /> + } + /> + + {flag.enabled ? "Disable flag" : "Enable flag"} + + + + onAssignUsers(flag.slug)} + size="icon" + variant="outline" + > + + + } + /> + Assign to users + + + + ))} + + + + + ); + + function handleToggle(slug: string, checked: boolean) { + onToggle(slug, checked); + } +} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/flags/page.tsx b/apps/examples/calcom/app/(settings)/settings/admin/flags/page.tsx new file mode 100644 index 000000000..751c1c0f7 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/flags/page.tsx @@ -0,0 +1,22 @@ +import { + AppHeader, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; + +import { FlagAdminList } from "./flag-admin-list"; + +export default function AdminFlagsPage() { + return ( + <> + + + + Manage killswitches and feature flags for your instance + + + + + + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/impersonation/page.tsx b/apps/examples/calcom/app/(settings)/settings/admin/impersonation/page.tsx new file mode 100644 index 000000000..4c31135dd --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/impersonation/page.tsx @@ -0,0 +1,109 @@ +import { Button } from "@coss/ui/components/button"; +import { + Card, + CardFrame, + CardFrameHeader, + CardFrameTitle, + CardPanel, +} from "@coss/ui/components/card"; +import { Field, FieldDescription } from "@coss/ui/components/field"; +import { Group } from "@coss/ui/components/group"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@coss/ui/components/input-group"; +import { + AppHeader, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; +import { + ListItem, + ListItemActions, + ListItemContent, + ListItemDescription, + ListItemHeader, + ListItemTitle, +} from "@/components/list-item"; + +const RECENT_IMPERSONATIONS = [ + { + impersonatedAt: "2/23/2026 5:21:37 PM", + user: "teampro", + }, + { + impersonatedAt: "2/23/2026 5:21:22 PM", + user: "platformadmin2024!", + }, +]; + +export default function AdminImpersonationPage() { + return ( + <> + + + Impersonation + + +
+ + + User impersonation + + + + + + + + http://localhost:3000/ + + + +
+ +
+
+ + All uses of this feature is audited. + +
+
+
+
+ + + + Recent impersonations + + + + {RECENT_IMPERSONATIONS.map((entry) => ( + + + + {entry.user} + + Impersonated at {entry.impersonatedAt} + + + + + + + + ))} + + + +
+ + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/admin/oauth/page.tsx b/apps/examples/calcom/app/(settings)/settings/admin/oauth/page.tsx new file mode 100644 index 000000000..898e5ec5d --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/admin/oauth/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { + Card, + CardFrame, + CardFrameHeader, + CardFrameTitle, + CardPanel, +} from "@coss/ui/components/card"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@coss/ui/components/empty"; +import { KeyRoundIcon } from "lucide-react"; +import { + type OAuthClientItem, + OAuthClientsList, +} from "@/app/(settings)/settings/developer/oauth/oauth-clients-list"; +import { + AppHeader, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; + +const STATUS_GROUPS = ["pending", "rejected", "approved"] as const; + +const STATUS_LABELS: Record<(typeof STATUS_GROUPS)[number], string> = { + approved: "Approved", + pending: "Pending", + rejected: "Rejected", +}; + +import { useState } from "react"; + +const OAUTH_CLIENTS: OAuthClientItem[] = [ + { + clientId: "cl_admin_1", + clientSecret: "cs_admin_1", + id: "1", + name: "another", + status: "pending", + }, + { + clientId: "cl_admin_2", + clientSecret: "cs_admin_2", + id: "2", + name: "test", + status: "pending", + }, +]; + +export default function AdminOAuthPage() { + const [clients, setClients] = useState(OAUTH_CLIENTS); + + const grouped = STATUS_GROUPS.map((status) => ({ + clients: clients.filter((c) => c.status === status), + label: STATUS_LABELS[status], + status, + })); + + function handleEditClick(_client: OAuthClientItem) { + // TODO: open edit dialog + } + + function handleRemoveClick(client: OAuthClientItem) { + setClients((prev) => prev.filter((c) => c.id !== client.id)); + } + + return ( + <> + + + + Manage and approve OAuth client submissions + + + +
+ {grouped.map((group) => ( + + + {group.label} + + + + {group.clients.length > 0 ? ( + + ) : ( + + + + + + + No {group.label.toLowerCase()} clients + + + There are no {group.label.toLowerCase()} OAuth client + submissions. + + + + )} + + + + ))} +
+ + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/billing/billing-page-content.tsx b/apps/examples/calcom/app/(settings)/settings/billing/billing-page-content.tsx new file mode 100644 index 000000000..b6bc0f754 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/billing/billing-page-content.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { Button } from "@coss/ui/components/button"; +import { Calendar } from "@coss/ui/components/calendar"; +import { + Card, + CardFrame, + CardFrameDescription, + CardFrameHeader, + CardFrameTitle, + CardPanel, +} from "@coss/ui/components/card"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, + ComboboxValue, +} from "@coss/ui/components/combobox"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@coss/ui/components/empty"; +import { Field, FieldDescription, FieldLabel } from "@coss/ui/components/field"; +import { FieldsetLegend } from "@coss/ui/components/fieldset"; +import { Group } from "@coss/ui/components/group"; +import { + InputGroup, + InputGroupAddon, + InputGroupText, +} from "@coss/ui/components/input-group"; +import { + NumberField, + NumberFieldInput, +} from "@coss/ui/components/number-field"; +import { + Popover, + PopoverPopup, + PopoverTrigger, +} from "@coss/ui/components/popover"; +import { SelectButton } from "@coss/ui/components/select"; +import { ExternalLinkIcon, FileTextIcon, SearchIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import type { DateRange } from "react-day-picker"; +import { + AppHeader, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; +import { FieldGrid } from "@/components/particles/field-grid"; + +const monthOptions = [ + { label: "January 2026", value: "January 2026" }, + { label: "February 2026", value: "February 2026" }, + { label: "March 2026", value: "March 2026" }, + { label: "April 2026", value: "April 2026" }, + { label: "May 2026", value: "May 2026" }, + { label: "June 2026", value: "June 2026" }, +]; + +export function BillingPageContent() { + const today = new Date(); + const subDays = (date: Date, days: number) => { + const next = new Date(date); + next.setDate(next.getDate() - days); + return next; + }; + const startOfMonth = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), 1); + const startOfYear = (date: Date) => new Date(date.getFullYear(), 0, 1); + const formatDate = (date: Date) => + new Intl.DateTimeFormat("en-US", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(date); + + const [credits, setCredits] = useState(50); + const [expenseLogMonth, setExpenseLogMonth] = useState("February 2026"); + const [invoiceRange, setInvoiceRange] = useState({ + from: subDays(today, 6), + to: today, + }); + const [invoiceMonth, setInvoiceMonth] = useState(today); + const [selectedInvoicePreset, setSelectedInvoicePreset] = useState< + string | null + >("last-7-days"); + + const applyInvoicePreset = ( + presetValue: string, + range: { from: Date; to: Date }, + ) => { + setInvoiceRange(range); + setSelectedInvoicePreset(presetValue); + setInvoiceMonth(range.to); + }; + + const invoicePresets = [ + { + label: "Today", + onClick: () => { + applyInvoicePreset("today", { from: today, to: today }); + }, + value: "today", + }, + { + label: "Last 7 days", + onClick: () => { + applyInvoicePreset("last-7-days", { + from: subDays(today, 6), + to: today, + }); + }, + value: "last-7-days", + }, + { + label: "Last 30 days", + onClick: () => { + applyInvoicePreset("last-30-days", { + from: subDays(today, 29), + to: today, + }); + }, + value: "last-30-days", + }, + { + label: "Month to date", + onClick: () => { + applyInvoicePreset("month-to-date", { + from: startOfMonth(today), + to: today, + }); + }, + value: "month-to-date", + }, + { + label: "Year to date", + onClick: () => { + applyInvoicePreset("year-to-date", { + from: startOfYear(today), + to: today, + }); + }, + value: "year-to-date", + }, + ]; + + const invoiceRangeLabel = + invoiceRange?.from && invoiceRange?.to + ? `${formatDate(invoiceRange.from)} - ${formatDate(invoiceRange.to)}` + : "Select date range"; + + return ( + <> + + + Manage all things billing + + +
+ + + +
+
+ Manage billing + + View and manage your billing details + +
+ +
+
+
+
+ + + + Credits + + View and manage credits for sending SMS messages + + + + + +
+ }> + Current balance:{" "} + 0 + +
+ +
+ Additional credits +
+ + + setCredits(value ?? 0)} + value={credits} + > + + + + Credits + + +
+ +
+
+ + One credit is worth 1¢ (USD). + +
+ + Download Expense Log + + + item && setExpenseLogMonth(item.value) + } + value={ + monthOptions.find((m) => m.value === expenseLogMonth) ?? + null + } + > + }> + + + +
+ } + /> +
+ No months found. + + {(item: (typeof monthOptions)[0]) => ( + + {item.label} + + )} + +
+
+
+ +
+
+
+
+
+
+
+ + + +
+ Invoices + + }> + {invoiceRangeLabel} + + +
+
+
+ {invoicePresets.map((preset) => ( + + ))} +
+
+ { + setInvoiceRange(range); + setSelectedInvoicePreset(null); + }} + selected={invoiceRange} + /> +
+
+
+
+
+ + + + + + + + No invoices found + + No invoices found in the selected date range. + + + + + +
+ +
+ Need help?{" "} + + Contact support + +
+
+ + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/billing/page.tsx b/apps/examples/calcom/app/(settings)/settings/billing/page.tsx new file mode 100644 index 000000000..185651ff3 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/billing/page.tsx @@ -0,0 +1,54 @@ +import { Button } from "@coss/ui/components/button"; +import { + Card, + CardFrameDescription, + CardFrameTitle, + CardPanel, +} from "@coss/ui/components/card"; +import { ExternalLinkIcon } from "lucide-react"; +import Link from "next/link"; +import { + AppHeader, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; + +export default function BillingPage() { + return ( + <> + + + Manage all things billing + + +
+ + +
+
+ Manage billing + + View and manage your billing details + +
+ +
+
+
+ +
+ Need help?{" "} + + Contact support + +
+
+ + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-empty.tsx b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-empty.tsx new file mode 100644 index 000000000..4ac3e7069 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-empty.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Button } from "@coss/ui/components/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@coss/ui/components/empty"; +import { KeyIcon, PlusIcon } from "lucide-react"; + +interface ApiKeysEmptyProps { + onNewClick: () => void; +} + +export function ApiKeysEmpty({ onNewClick }: ApiKeysEmptyProps) { + return ( + + + + + + Create your first API key + + API keys allow other apps to communicate with Cal.com + + + + + + + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-list.tsx b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-list.tsx new file mode 100644 index 000000000..7dca3eebb --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-list.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Badge } from "@coss/ui/components/badge"; +import { Button } from "@coss/ui/components/button"; +import { + Menu, + MenuItem, + MenuPopup, + MenuTrigger, +} from "@coss/ui/components/menu"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@coss/ui/components/tooltip"; +import { EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { + ListItem, + ListItemActions, + ListItemBadges, + ListItemContent, + ListItemDescription, + ListItemHeader, + ListItemTitle, +} from "@/components/list-item"; + +export interface ApiKeyItem { + id: string; + note: string; + key: string; + expiresAt: string | null; + createdAt: string; + neverExpires: boolean; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function isExpired(item: ApiKeyItem): boolean { + if (item.neverExpires || !item.expiresAt) return false; + return new Date(item.expiresAt) < new Date(); +} + +function getStatusLabel(item: ApiKeyItem): string { + return isExpired(item) ? "Expired" : "Active"; +} + +function getStatusVariant(item: ApiKeyItem): "success" | "error" { + return isExpired(item) ? "error" : "success"; +} + +function getExpirationDescription(item: ApiKeyItem): string { + if (item.neverExpires) return "Never expires"; + if (!item.expiresAt) return "No expiration set"; + const expiresDate = new Date(item.expiresAt); + const now = new Date(); + if (expiresDate < now) return `Expired ${formatDate(item.expiresAt)}`; + return `Expires ${formatDate(item.expiresAt)}`; +} + +export function ApiKeysList({ + apiKeys, + onEditClick, + onRemoveClick, +}: { + apiKeys: ApiKeyItem[]; + onEditClick: (apiKey: ApiKeyItem) => void; + onRemoveClick: (apiKey: ApiKeyItem) => void; +}) { + return ( + <> + {apiKeys.map((apiKey) => ( + + + + {apiKey.note || "Untitled API Key"} + + {getExpirationDescription(apiKey)} + + + + + + {getStatusLabel(apiKey)} + + + + + + + + + } + /> + } + /> + Options + + + onEditClick(apiKey)}> + + Edit + + onRemoveClick(apiKey)} + variant="destructive" + > + + Delete + + + + + + ))} + + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-page-content.tsx b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-page-content.tsx new file mode 100644 index 000000000..b97d8f9b6 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/api-keys-page-content.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "@coss/ui/components/alert-dialog"; +import { Button } from "@coss/ui/components/button"; +import { Card, CardPanel } from "@coss/ui/components/card"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, + AppHeaderDescription, +} from "@/components/app/app-header"; +import { ApiKeysEmpty } from "./api-keys-empty"; +import type { ApiKeyItem } from "./api-keys-list"; +import { ApiKeysList } from "./api-keys-list"; +import { EditApiKeyDialog } from "./edit-api-key-dialog"; +import { NewApiKeyDialog } from "./new-api-key-dialog"; + +const initialMockApiKeys: ApiKeyItem[] = [ + { + createdAt: "2025-11-15T10:30:00Z", + expiresAt: null, + id: "1", + key: "cal_live_mock_key_1", + neverExpires: true, + note: "Production API", + }, + { + createdAt: "2026-01-20T14:00:00Z", + expiresAt: "2026-07-20T14:00:00Z", + id: "2", + key: "cal_live_mock_key_2", + neverExpires: false, + note: "Development testing", + }, +]; + +export function ApiKeysPageContent() { + const [apiKeys, setApiKeys] = useState(initialMockApiKeys); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingKey, setEditingKey] = useState(null); + const [revokeDialogOpen, setRevokeDialogOpen] = useState(false); + const [keyToRevoke, setKeyToRevoke] = useState(null); + + const hasApiKeys = apiKeys.length > 0; + + function handleEditClick(apiKey: ApiKeyItem) { + setEditingKey(apiKey); + setEditDialogOpen(true); + } + + function handleRemoveClick(apiKey: ApiKeyItem) { + setKeyToRevoke(apiKey); + setRevokeDialogOpen(true); + } + + function handleRevokeConfirm() { + if (keyToRevoke) { + setApiKeys((prev) => prev.filter((k) => k.id !== keyToRevoke.id)); + setKeyToRevoke(null); + } + setRevokeDialogOpen(false); + } + + function handleRevokeDialogOpenChange(open: boolean) { + if (!open) { + setKeyToRevoke(null); + } + setRevokeDialogOpen(open); + } + + return ( + <> + + + + Create and manage API keys for authenticating with the Cal.com API + + + {hasApiKeys && ( + + + + )} + + {hasApiKeys ? ( + + + + + + ) : ( + setCreateDialogOpen(true)} /> + )} + + + + + + + + + + Permanently remove this API key from your account? + + + This will permanently delete the API key. Any applications using + this key will immediately lose access to your account. This action + cannot be undone. + + + + }> + Cancel + + Revoke this API key + } + /> + + + + + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/api-keys/edit-api-key-dialog.tsx b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/edit-api-key-dialog.tsx new file mode 100644 index 000000000..be607d64f --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/edit-api-key-dialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Button } from "@coss/ui/components/button"; +import { + Dialog, + DialogClose, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@coss/ui/components/dialog"; +import { Field, FieldLabel } from "@coss/ui/components/field"; +import { Form } from "@coss/ui/components/form"; +import { Input } from "@coss/ui/components/input"; +import type { ApiKeyItem } from "./api-keys-list"; + +interface EditApiKeyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + apiKey: ApiKeyItem | null; +} + +export function EditApiKeyDialog({ + open, + onOpenChange, + apiKey, +}: EditApiKeyDialogProps) { + return ( + + + {apiKey && ( + { + e.preventDefault(); + onOpenChange(false); + }} + > + + Edit API key + + + + Name this key + + + + + }> + Cancel + + + + + )} + + + ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/api-keys/new-api-key-dialog.tsx b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/new-api-key-dialog.tsx new file mode 100644 index 000000000..03d212418 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/new-api-key-dialog.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@coss/ui/components/alert"; +import { Button } from "@coss/ui/components/button"; +import { + Collapsible, + CollapsiblePanel, + CollapsibleTrigger, +} from "@coss/ui/components/collapsible"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@coss/ui/components/dialog"; +import { Field, FieldDescription, FieldLabel } from "@coss/ui/components/field"; +import { Form } from "@coss/ui/components/form"; +import { Input } from "@coss/ui/components/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "@coss/ui/components/select"; +import { Switch } from "@coss/ui/components/switch"; +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { CopyableField } from "../oauth/copyable-field"; + +type Step = "form" | "submitted"; + +interface NewApiKeyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function NewApiKeyDialog({ open, onOpenChange }: NewApiKeyDialogProps) { + const [step, setStep] = useState("form"); + const [neverExpires, setNeverExpires] = useState(false); + const [generatedKey, setGeneratedKey] = useState(""); + + useEffect(() => { + if (open) { + setStep("form"); + setNeverExpires(false); + setGeneratedKey(""); + } + }, [open]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setGeneratedKey("cal_live_mock_api_key"); + setStep("submitted"); + e.currentTarget.reset(); + } + + const isFormStep = step === "form"; + + return ( + + + {isFormStep ? ( +
+ + Create an API key + + API keys allow you to make API calls for your own account. + + + + + + + Here we can say something about OAuth with a link to the docs. + + + + Name this key + + + + setNeverExpires(!open)} + open={!neverExpires} + > + + + + setNeverExpires(checked === true) + } + /> + } + /> + Never expires + + + + + Expiration + + + The API key will expire on 21-03-2026 + + + + + + + }> + Cancel + + + +
+ ) : ( + <> + + API key created successfully + + Your new API key has been created. Copy it now — you won't + be able to see it again. + + + + + + Save this API key somewhere safe + + You will not be able to view it again once you close this + modal. + + + + + + }>Done + + + )} +
+
+ ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/api-keys/page.tsx b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/page.tsx new file mode 100644 index 000000000..4a160395e --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/api-keys/page.tsx @@ -0,0 +1,5 @@ +import { ApiKeysPageContent } from "./api-keys-page-content"; + +export default function ApiKeysSettingsPage() { + return ; +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/oauth/copyable-field.tsx b/apps/examples/calcom/app/(settings)/settings/developer/oauth/copyable-field.tsx new file mode 100644 index 000000000..61cda2d20 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/oauth/copyable-field.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Button } from "@coss/ui/components/button"; +import { Field, FieldDescription, FieldLabel } from "@coss/ui/components/field"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@coss/ui/components/input-group"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@coss/ui/components/tooltip"; +import { useCopyToClipboard } from "@coss/ui/hooks/use-copy-to-clipboard"; +import { CheckIcon, CopyIcon } from "lucide-react"; + +interface CopyableFieldProps { + label: string; + value: string; + "aria-label": string; + description?: React.ReactNode; +} + +export function CopyableField({ + label, + value, + "aria-label": ariaLabel, + description, +}: CopyableFieldProps) { + const { copyToClipboard, isCopied } = useCopyToClipboard(); + + return ( + + {label} + + + + + copyToClipboard(value)} + size="icon-xs" + variant="ghost" + /> + } + > + {isCopied ? : } + + +

{isCopied ? "Copied!" : "Copy to clipboard"}

+
+
+
+
+ {description && {description}} +
+ ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/oauth/edit-oauth-client-dialog.tsx b/apps/examples/calcom/app/(settings)/settings/developer/oauth/edit-oauth-client-dialog.tsx new file mode 100644 index 000000000..b6ef4ab1a --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/oauth/edit-oauth-client-dialog.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Badge } from "@coss/ui/components/badge"; +import { Button } from "@coss/ui/components/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@coss/ui/components/dialog"; +import { Field, FieldLabel } from "@coss/ui/components/field"; +import { Form } from "@coss/ui/components/form"; +import { Input } from "@coss/ui/components/input"; +import { CopyableField } from "./copyable-field"; +import { OAuthClientFormFields } from "./oauth-client-form-fields"; +import type { OAuthClientItem } from "./oauth-clients-list"; + +const statusVariantMap = { + approved: "success", + pending: "warning", + rejected: "error", +} as const; + +const statusLabelMap = { + approved: "Approved", + pending: "Pending", + rejected: "Rejected", +} as const; + +interface EditOAuthClientDialogProps { + onOpenChange: (open: boolean) => void; + open: boolean; + client: OAuthClientItem | null; +} + +export function EditOAuthClientDialog({ + onOpenChange, + open, + client, +}: EditOAuthClientDialogProps) { + return ( + + + {client && ( +
{ + e.preventDefault(); + onOpenChange(false); + }} + > + + Edit OAuth client + + View and manage your OAuth client settings. + + + +
+ + {statusLabelMap[client.status]} + +
+ + + + + Client name + + + + +
+ + }> + Cancel + + + +
+ )} +
+
+ ); +} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/oauth/new-oauth-client-dialog.tsx b/apps/examples/calcom/app/(settings)/settings/developer/oauth/new-oauth-client-dialog.tsx new file mode 100644 index 000000000..5a3dba353 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/oauth/new-oauth-client-dialog.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Alert, AlertDescription } from "@coss/ui/components/alert"; +import { Badge } from "@coss/ui/components/badge"; +import { Button } from "@coss/ui/components/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@coss/ui/components/dialog"; +import { Field, FieldLabel } from "@coss/ui/components/field"; +import { Form } from "@coss/ui/components/form"; +import { Input } from "@coss/ui/components/input"; +import { TriangleAlertIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { CopyableField } from "./copyable-field"; +import { OAuthClientFormFields } from "./oauth-client-form-fields"; + +interface OAuthClientSubmittedData { + clientId: string; + clientSecret: string; + name: string; +} + +type Step = "form" | "submitted"; + +interface NewOAuthClientDialogRootProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function NewOAuthClientDialogRoot({ + open, + onOpenChange, +}: NewOAuthClientDialogRootProps) { + const [step, setStep] = useState("form"); + const [submittedData, setSubmittedData] = + useState(null); + + useEffect(() => { + if (open) { + setStep("form"); + setSubmittedData(null); + } + }, [open]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + const name = (formData.get("clientName") as string) || "My OAuth App"; + setSubmittedData({ + clientId: "cl_mock_1", + clientSecret: "cs_mock_1", + name, + }); + setStep("submitted"); + form.reset(); + } + + const isFormStep = step === "form"; + + return ( + + + {isFormStep ? ( +
+ + Create OAuth client + + Create a new OAuth client to allow third-party applications to + access Cal.com on behalf of your users. + + + + + + + }> + Cancel + + + +
+ ) : ( + submittedData && ( + <> + + OAuth Client Submitted + + Your OAuth client has been submitted for approval. You will + receive an email if it is approved or rejected. The OAuth + client can't be used unless approved. + + + +
+ Pending +
+ + Name + + + + + + + + This client secret is shown only once. Copy it now — you + won't be able to view it again after closing this + dialog. + + +
+ + }>Done + + + ) + )} +
+
+ ); +} + +export { NewOAuthClientDialogRoot }; diff --git a/apps/examples/calcom/app/(settings)/settings/developer/oauth/oauth-client-form-fields.tsx b/apps/examples/calcom/app/(settings)/settings/developer/oauth/oauth-client-form-fields.tsx new file mode 100644 index 000000000..6ee1f6d42 --- /dev/null +++ b/apps/examples/calcom/app/(settings)/settings/developer/oauth/oauth-client-form-fields.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Avatar, AvatarFallback } from "@coss/ui/components/avatar"; +import { Button } from "@coss/ui/components/button"; +import { Field, FieldDescription, FieldLabel } from "@coss/ui/components/field"; +import { Input } from "@coss/ui/components/input"; +import { Label } from "@coss/ui/components/label"; +import { Switch } from "@coss/ui/components/switch"; +import { Textarea } from "@coss/ui/components/textarea"; +import { KeyIcon } from "lucide-react"; + +export interface OAuthClientFormDefaults { + clientName?: string; + purpose?: string; + redirectUri?: string; + usePkce?: boolean; + websiteUrl?: string; +} + +interface OAuthClientFormFieldsProps { + defaultValues?: OAuthClientFormDefaults; + includeClientName?: boolean; +} + +export function OAuthClientFormFields({ + defaultValues, + includeClientName = true, +}: OAuthClientFormFieldsProps) { + return ( + <> + {includeClientName && ( + + Client name + + + )} + + + Purpose +