Compare commits
No commits in common. "main" and "feature/team-invitations" have entirely different histories.
main
...
feature/te
11 changed files with 82 additions and 179 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,12 +12,11 @@
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
/playwright-report/
|
|
||||||
/test-results/
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!account_id || !userId) {
|
if (!account_id || !userId) {
|
||||||
|
console.error("Unipile callback missing account_id or name:", body);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing account_id or user identifier" },
|
{ error: "Missing account_id or user identifier" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|
@ -28,22 +29,18 @@ export async function POST(request: NextRequest) {
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { error } = await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.update({
|
.update({
|
||||||
unipile_account_id: account_id,
|
unipile_account_id: account_id,
|
||||||
unipile_account_status: status ?? "CONNECTED",
|
unipile_account_status: status ?? "CONNECTED",
|
||||||
})
|
})
|
||||||
.eq("id", userId)
|
.eq("id", userId);
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
console.error("Error saving Unipile account_id:", error);
|
||||||
return NextResponse.json({ error: "DB update failed" }, { status: 500 });
|
return NextResponse.json({ error: "DB update failed" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return NextResponse.json({ error: "Profile not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,16 +29,9 @@ export async function POST() {
|
||||||
|
|
||||||
const type = profile?.unipile_account_id ? "reconnect" : "create";
|
const type = profile?.unipile_account_id ? "reconnect" : "create";
|
||||||
|
|
||||||
// expiresOn: 1 hour from now (links also expire on daily restart regardless)
|
|
||||||
const expiresOn = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
||||||
// api_url must be the full Unipile server URL derived from the DSN
|
|
||||||
const api_url = `https://${dsn}`;
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
type,
|
type,
|
||||||
providers: ["LINKEDIN"],
|
providers: ["LINKEDIN"],
|
||||||
expiresOn,
|
|
||||||
api_url,
|
|
||||||
notify_url: `${appUrl}/api/linkedin/callback`,
|
notify_url: `${appUrl}/api/linkedin/callback`,
|
||||||
success_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=connected`,
|
success_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=connected`,
|
||||||
failure_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=failed`,
|
failure_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=failed`,
|
||||||
|
|
@ -46,9 +39,9 @@ export async function POST() {
|
||||||
name: userData.user.id,
|
name: userData.user.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
// For reconnect, the field is reconnect_account (not account_id)
|
// For reconnect we need the existing account_id
|
||||||
if (type === "reconnect" && profile?.unipile_account_id) {
|
if (type === "reconnect" && profile?.unipile_account_id) {
|
||||||
body.reconnect_account = profile.unipile_account_id;
|
body.account_id = profile.unipile_account_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`https://${dsn}/api/v1/hosted/accounts/link`, {
|
const response = await fetch(`https://${dsn}/api/v1/hosted/accounts/link`, {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,13 @@
|
||||||
import { SignUpForm } from "@/components/sign-up-form";
|
import { SignUpForm } from "@/components/sign-up-form";
|
||||||
import { Suspense } from "react";
|
|
||||||
|
|
||||||
export default function Page({
|
export default async function Page({ searchParams }: { searchParams: Promise<{ email?: string }> }) {
|
||||||
searchParams,
|
const email = (await searchParams).email || "";
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ email?: string }>;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<Suspense>
|
<SignUpForm initialEmail={email} />
|
||||||
<SignUpFormContent searchParams={searchParams} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function SignUpFormContent({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ email?: string }>;
|
|
||||||
}) {
|
|
||||||
const email = (await searchParams).email || "";
|
|
||||||
return <SignUpForm initialEmail={email} />;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@ async function AccountSettingsContent() {
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
const isConnected = !!profile?.unipile_account_id;
|
const isConnected = !!profile?.unipile_account_id;
|
||||||
|
const accountId = profile?.unipile_account_id as string | null;
|
||||||
|
const status = profile?.unipile_account_status as string | null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl mx-auto space-y-8">
|
<div className="p-8 max-w-2xl mx-auto space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold mb-1 text-foreground">Account Settings</h1>
|
<h1 className="text-2xl font-semibold mb-1 text-foreground">Account Settings</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Manage your personal account and integrations.
|
Manage your personal account and integrations.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -31,8 +33,8 @@ async function AccountSettingsContent() {
|
||||||
|
|
||||||
{/* LinkedIn Integration */}
|
{/* LinkedIn Integration */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-medium text-foreground border-b border-border pb-2 flex items-center gap-2">
|
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2 flex items-center gap-2">
|
||||||
<Linkedin className="h-6 w-6 text-[#0077B5]" />
|
<Linkedin className="h-5 w-5 text-[#0077B5]" />
|
||||||
LinkedIn Integration
|
LinkedIn Integration
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -60,7 +62,15 @@ async function AccountSettingsContent() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isConnected && (
|
||||||
|
<div className="bg-muted/50 rounded-md p-3 text-xs font-mono text-muted-foreground break-all">
|
||||||
|
<span className="text-foreground/60 mr-2">Account ID:</span>
|
||||||
|
{accountId}
|
||||||
|
{status && status !== "CONNECTED" && (
|
||||||
|
<span className="ml-2 text-amber-500">({status})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
|
|
@ -77,7 +87,7 @@ async function AccountSettingsContent() {
|
||||||
|
|
||||||
function AccountSettingsSkeleton() {
|
function AccountSettingsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl mx-auto space-y-8">
|
<div className="p-8 max-w-2xl mx-auto space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<Skeleton className="h-8 w-48 mb-2" />
|
<Skeleton className="h-8 w-48 mb-2" />
|
||||||
<Skeleton className="h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
|
|
|
||||||
|
|
@ -3,83 +3,45 @@
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
export type InviteStatus = {
|
export async function inviteUser(formData: FormData) {
|
||||||
success?: boolean;
|
|
||||||
error?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function inviteUser(prevState: InviteStatus | null, formData: FormData): Promise<InviteStatus> {
|
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return { error: "Email is required" };
|
throw new Error("Email is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const supabase = await createClient();
|
||||||
const supabase = await createClient();
|
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (userError || !userData.user) {
|
if (userError || !userData.user) {
|
||||||
return { error: "Unauthorized" };
|
throw new Error("Unauthorized");
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("organization_id, role")
|
|
||||||
.eq("id", userData.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!profile || profile.role !== "owner" || !profile.organization_id) {
|
|
||||||
return { error: "Only organization owners can invite new members" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already a member
|
|
||||||
const { data: existingMember } = await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("id")
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("organization_id", profile.organization_id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existingMember) {
|
|
||||||
return { error: "User is already a member of this organization" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's already a pending invitation
|
|
||||||
const { data: existingInvitation } = await supabase
|
|
||||||
.from("invitations")
|
|
||||||
.select("id")
|
|
||||||
.eq("email", email)
|
|
||||||
.eq("organization_id", profile.organization_id)
|
|
||||||
.eq("status", "pending")
|
|
||||||
.gt("expires_at", new Date().toISOString())
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existingInvitation) {
|
|
||||||
return { error: "An invitation is already pending for this email address" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: inviteError } = await supabase
|
|
||||||
.from("invitations")
|
|
||||||
.insert([
|
|
||||||
{
|
|
||||||
email,
|
|
||||||
organization_id: profile.organization_id,
|
|
||||||
invited_by: userData.user.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (inviteError) {
|
|
||||||
console.error("Error creating invitation:", inviteError);
|
|
||||||
return { error: "Failed to create invitation" };
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/dashboard/settings/team");
|
|
||||||
return { success: true, message: "Invitation sent successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Unexpected error inviting user:", error);
|
|
||||||
return { error: "An unexpected error occurred" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("organization_id, role")
|
||||||
|
.eq("id", userData.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!profile || profile.role !== "owner" || !profile.organization_id) {
|
||||||
|
throw new Error("Only organization owners can invite new members");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: inviteError } = await supabase
|
||||||
|
.from("invitations")
|
||||||
|
.insert([
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
organization_id: profile.organization_id,
|
||||||
|
invited_by: userData.user.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (inviteError) {
|
||||||
|
console.error("Error creating invitation:", inviteError);
|
||||||
|
throw new Error("Failed to create invitation");
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/settings/team");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeInvitation(formData: FormData) {
|
export async function revokeInvitation(formData: FormData) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { revokeInvitation } from "./actions";
|
import { inviteUser, revokeInvitation } from "./actions";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { InviteMemberForm } from "@/components/invite-member-form";
|
|
||||||
|
|
||||||
async function TeamSettingsContent() {
|
async function TeamSettingsContent() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
@ -62,7 +61,21 @@ async function TeamSettingsContent() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2">Invite New Member</h2>
|
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2">Invite New Member</h2>
|
||||||
<div className="bg-card border border-border rounded-lg p-4 shadow-sm">
|
<div className="bg-card border border-border rounded-lg p-4 shadow-sm">
|
||||||
<InviteMemberForm />
|
<form action={inviteUser} className="flex gap-4">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
required
|
||||||
|
className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
Send Invite
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 110%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
@ -7,11 +7,6 @@ const defaultUrl = process.env.VERCEL_URL
|
||||||
? `https://${process.env.VERCEL_URL}`
|
? `https://${process.env.VERCEL_URL}`
|
||||||
: "http://localhost:3000";
|
: "http://localhost:3000";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(defaultUrl),
|
metadataBase: new URL(defaultUrl),
|
||||||
title: "Next.js and Supabase Starter Kit",
|
title: "Next.js and Supabase Starter Kit",
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useActionState, useEffect, useRef } from "react";
|
|
||||||
import { inviteUser, type InviteStatus } from "@/app/dashboard/settings/team/actions";
|
|
||||||
|
|
||||||
export function InviteMemberForm() {
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const [state, formAction, isPending] = useActionState<InviteStatus | null, FormData>(
|
|
||||||
inviteUser,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state?.success) {
|
|
||||||
formRef.current?.reset();
|
|
||||||
}
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<form ref={formRef} action={formAction} className="flex gap-4">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
placeholder="Email address"
|
|
||||||
required
|
|
||||||
disabled={isPending}
|
|
||||||
className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors flex-shrink-0 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isPending ? "Sending..." : "Send Invite"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{state?.error && (
|
|
||||||
<p className="text-sm text-destructive mt-1 animate-in fade-in slide-in-from-top-1">{state.error}</p>
|
|
||||||
)}
|
|
||||||
{state?.success && (
|
|
||||||
<p className="text-sm text-green-500 mt-1 animate-in fade-in slide-in-from-top-1">{state.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -70,7 +70,7 @@ export const Sidebar: FC = () => {
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-screen border-r border-border bg-background transition-all duration-300",
|
"flex flex-col h-screen border-r border-border bg-background transition-all duration-300",
|
||||||
collapsed ? "w-16" : "w-72"
|
collapsed ? "w-16" : "w-64"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border h-16">
|
<div className="flex items-center justify-between p-4 border-b border-border h-16">
|
||||||
|
|
@ -79,7 +79,7 @@ export const Sidebar: FC = () => {
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className={cn("p-1 rounded-md hover:bg-muted transition-colors flex-shrink-0", collapsed && "mx-auto")}
|
className={cn("p-1 rounded-md hover:bg-muted transition-colors flex-shrink-0", collapsed && "mx-auto")}
|
||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight size={20} /> : <ChevronLeft size={20} />}
|
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -98,7 +98,7 @@ export const Sidebar: FC = () => {
|
||||||
collapsed && "justify-center px-0"
|
collapsed && "justify-center px-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={20} className="shrink-0" />
|
<Icon size={18} className="shrink-0" />
|
||||||
{!collapsed && <span>{item.label}</span>}
|
{!collapsed && <span>{item.label}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
@ -111,8 +111,8 @@ export const Sidebar: FC = () => {
|
||||||
"flex items-center gap-3 w-full p-2 rounded-md hover:bg-muted text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-muted",
|
"flex items-center gap-3 w-full p-2 rounded-md hover:bg-muted text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-muted",
|
||||||
collapsed ? "justify-center px-0" : "px-3"
|
collapsed ? "justify-center px-0" : "px-3"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-center bg-accent text-accent-foreground rounded-full w-9 h-9 shrink-0 overflow-hidden">
|
<div className="flex items-center justify-center bg-accent text-accent-foreground rounded-full w-8 h-8 shrink-0 overflow-hidden">
|
||||||
<User size={18} />
|
<User size={16} />
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="truncate flex-1 text-left font-medium">
|
<span className="truncate flex-1 text-left font-medium">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue