Skip to content

Commit 1a2ac28

Browse files
committed
add anonymous access toggle
1 parent 7f288c3 commit 1a2ac28

File tree

10 files changed

+280
-112
lines changed

10 files changed

+280
-112
lines changed

packages/web/src/actions.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
3737
import { createLogger } from "@sourcebot/logger";
3838
import { getAuditService } from "@/ee/features/audit/factory";
3939
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
40-
import { orgMetadataSchema } from "@/types";
40+
import { getOrgMetadata } from "@/types";
4141
import { getOrgFromDomain } from "./data/org";
4242

4343
const ajv = new Ajv({
@@ -1952,16 +1952,16 @@ export const getAnonymousAccessStatus = async (domain: string): Promise<boolean
19521952
return false;
19531953
}
19541954

1955-
const orgMetadata = orgMetadataSchema.safeParse(org.metadata);
1956-
if (!orgMetadata.success) {
1955+
const orgMetadata = getOrgMetadata(org);
1956+
if (!orgMetadata) {
19571957
return {
19581958
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
19591959
errorCode: ErrorCode.INVALID_ORG_METADATA,
19601960
message: "Invalid organization metadata",
19611961
} satisfies ServiceError;
19621962
}
19631963

1964-
return !!orgMetadata.data.anonymousAccessEnabled;
1964+
return !!orgMetadata.anonymousAccessEnabled;
19651965
});
19661966

19671967
export const setAnonymousAccessStatus = async (domain: string, enabled: boolean): Promise<ServiceError | boolean> => sew(async () => {
@@ -1978,9 +1978,9 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean)
19781978
} satisfies ServiceError;
19791979
}
19801980

1981-
const currentMetadata = orgMetadataSchema.safeParse(org.metadata);
1981+
const currentMetadata = getOrgMetadata(org);
19821982
const mergedMetadata = {
1983-
...(currentMetadata.success ? currentMetadata.data : {}),
1983+
...(currentMetadata ?? {}),
19841984
anonymousAccessEnabled: enabled,
19851985
};
19861986

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { getOrgFromDomain } from "@/data/org";
2+
import { getAnonymousAccessStatus } from "@/actions";
3+
import { hasEntitlement } from "@sourcebot/shared";
4+
import { isServiceError, getBaseUrl, createInviteLink } from "@/lib/utils";
5+
import { headers } from "next/headers";
6+
import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings";
7+
8+
interface AccessPageProps {
9+
params: {
10+
domain: string;
11+
}
12+
}
13+
14+
export default async function AccessPage({ params: { domain } }: AccessPageProps) {
15+
const org = await getOrgFromDomain(domain);
16+
if (!org) {
17+
throw new Error("Organization not found");
18+
}
19+
20+
// Get the current URL to construct the full invite link
21+
const headersList = headers();
22+
const baseUrl = getBaseUrl(headersList);
23+
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId);
24+
25+
// Get anonymous access status
26+
const anonymousAccessEntitlement = hasEntitlement("anonymous-access");
27+
const anonymousAccessStatus = await getAnonymousAccessStatus(domain);
28+
const anonymousAccessEnabled = anonymousAccessEntitlement && !isServiceError(anonymousAccessStatus) && anonymousAccessStatus;
29+
30+
return (
31+
<div className="flex flex-col gap-6">
32+
<div>
33+
<h3 className="text-lg font-medium">Access Control</h3>
34+
<p className="text-sm text-muted-foreground">Configure how users can access your Sourcebot deployment.</p>
35+
</div>
36+
37+
<OrganizationAccessSettings
38+
anonymousAccessEnabled={anonymousAccessEnabled}
39+
memberApprovalRequired={org.memberApprovalRequired}
40+
inviteLinkEnabled={org.inviteLinkEnabled}
41+
inviteLink={inviteLink}
42+
/>
43+
</div>
44+
)
45+
}

packages/web/src/app/[domain]/settings/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export default async function SettingsLayout({
6464
href: `/${domain}/settings/billing`,
6565
}
6666
] : []),
67+
{
68+
title: "Access",
69+
href: `/${domain}/settings/access`,
70+
},
6771
{
6872
title: (
6973
<div className="flex items-center gap-2">

packages/web/src/app/[domain]/settings/members/page.tsx

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ import { ServiceErrorException } from "@/lib/serviceError";
1212
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
1313
import { RequestsList } from "./components/requestsList";
1414
import { OrgRole } from "@prisma/client";
15-
import { MemberApprovalRequiredToggle } from "@/app/onboard/components/memberApprovalRequiredToggle";
16-
import { headers } from "next/headers";
17-
import { getBaseUrl, createInviteLink } from "@/lib/utils";
1815

1916
interface MembersSettingsPageProps {
2017
params: {
@@ -62,11 +59,6 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
6259
const usedSeats = members.length
6360
const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats;
6461

65-
// Get the current URL to construct the full invite link
66-
const headersList = headers();
67-
const baseUrl = getBaseUrl(headersList);
68-
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId);
69-
7062
return (
7163
<div className="flex flex-col gap-6">
7264
<div className="flex items-start justify-between">
@@ -86,10 +78,6 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
8678
)}
8779
</div>
8880

89-
{userRoleInOrg === OrgRole.OWNER && (
90-
<MemberApprovalRequiredToggle memberApprovalRequired={org.memberApprovalRequired} inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} />
91-
)}
92-
9381
<InviteMemberCard
9482
currentUserRole={userRoleInOrg}
9583
isBillingEnabled={IS_BILLING_ENABLED}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { Switch } from "@/components/ui/switch"
5+
import { setAnonymousAccessStatus } from "@/actions"
6+
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
7+
import { isServiceError } from "@/lib/utils"
8+
import { useToast } from "@/components/hooks/use-toast"
9+
10+
interface AnonymousAccessToggleProps {
11+
anonymousAccessEnabled: boolean
12+
onToggleChange?: (checked: boolean) => void
13+
}
14+
15+
export function AnonymousAccessToggle({ anonymousAccessEnabled, onToggleChange }: AnonymousAccessToggleProps) {
16+
const [enabled, setEnabled] = useState(anonymousAccessEnabled)
17+
const [isLoading, setIsLoading] = useState(false)
18+
const { toast } = useToast()
19+
20+
const handleToggle = async (checked: boolean) => {
21+
setIsLoading(true)
22+
try {
23+
const result = await setAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN, checked)
24+
25+
if (isServiceError(result)) {
26+
toast({
27+
title: "Error",
28+
description: result.message || "Failed to update anonymous access setting",
29+
variant: "destructive",
30+
})
31+
return
32+
}
33+
34+
setEnabled(checked)
35+
onToggleChange?.(checked)
36+
} catch (error) {
37+
console.error("Error updating anonymous access setting:", error)
38+
toast({
39+
title: "Error",
40+
description: "Failed to update anonymous access setting",
41+
variant: "destructive",
42+
})
43+
} finally {
44+
setIsLoading(false)
45+
}
46+
}
47+
48+
return (
49+
<div className="p-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
50+
<div className="flex items-start justify-between gap-4">
51+
<div className="flex-1 min-w-0">
52+
<h3 className="font-medium text-[var(--foreground)] mb-2">
53+
Enable anonymous access
54+
</h3>
55+
<div className="max-w-2xl">
56+
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
57+
When enabled, users can access your deployment without logging in.{" "}
58+
<a
59+
href="https://docs.sourcebot.dev/docs/configuration/auth/anonymous-access"
60+
target="_blank"
61+
rel="noopener"
62+
className="underline text-[var(--primary)] hover:text-[var(--primary)]/80 transition-colors"
63+
>
64+
Learn More
65+
</a>
66+
</p>
67+
</div>
68+
</div>
69+
<div className="flex-shrink-0">
70+
<Switch
71+
checked={enabled}
72+
onCheckedChange={handleToggle}
73+
disabled={isLoading}
74+
/>
75+
</div>
76+
</div>
77+
</div>
78+
)
79+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { Switch } from "@/components/ui/switch"
5+
import { setMemberApprovalRequired } from "@/actions"
6+
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
7+
import { isServiceError } from "@/lib/utils"
8+
import { useToast } from "@/components/hooks/use-toast"
9+
10+
interface MemberApprovalRequiredToggleProps {
11+
memberApprovalRequired: boolean
12+
onToggleChange?: (checked: boolean) => void
13+
}
14+
15+
export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleChange }: MemberApprovalRequiredToggleProps) {
16+
const [enabled, setEnabled] = useState(memberApprovalRequired)
17+
const [isLoading, setIsLoading] = useState(false)
18+
const { toast } = useToast()
19+
20+
const handleToggle = async (checked: boolean) => {
21+
setIsLoading(true)
22+
try {
23+
const result = await setMemberApprovalRequired(SINGLE_TENANT_ORG_DOMAIN, checked)
24+
25+
if (isServiceError(result)) {
26+
toast({
27+
title: "Error",
28+
description: "Failed to update member approval setting",
29+
variant: "destructive",
30+
})
31+
return
32+
}
33+
34+
setEnabled(checked)
35+
onToggleChange?.(checked)
36+
} catch (error) {
37+
console.error("Error updating member approval setting:", error)
38+
toast({
39+
title: "Error",
40+
description: "Failed to update member approval setting",
41+
variant: "destructive",
42+
})
43+
} finally {
44+
setIsLoading(false)
45+
}
46+
}
47+
48+
return (
49+
<div className="p-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
50+
<div className="flex items-start justify-between gap-4">
51+
<div className="flex-1 min-w-0">
52+
<h3 className="font-medium text-[var(--foreground)] mb-2">
53+
Require approval for new members
54+
</h3>
55+
<div className="max-w-2xl">
56+
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
57+
When enabled, new users will need approval from an organization owner before they can access your deployment.{" "}
58+
<a
59+
href="https://docs.sourcebot.dev/docs/configuration/auth/inviting-members"
60+
target="_blank"
61+
rel="noopener"
62+
className="underline text-[var(--primary)] hover:text-[var(--primary)]/80 transition-colors"
63+
>
64+
Learn More
65+
</a>
66+
</p>
67+
</div>
68+
</div>
69+
<div className="flex-shrink-0">
70+
<Switch
71+
checked={enabled}
72+
onCheckedChange={handleToggle}
73+
disabled={isLoading}
74+
/>
75+
</div>
76+
</div>
77+
</div>
78+
)
79+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { AnonymousAccessToggle } from "./anonymousAccessToggle"
5+
import { MemberApprovalRequiredToggle } from "./memberApprovalRequiredToggle"
6+
import { InviteLinkToggle } from "./inviteLinkToggle"
7+
8+
interface OrganizationAccessSettingsProps {
9+
anonymousAccessEnabled: boolean
10+
memberApprovalRequired: boolean
11+
inviteLinkEnabled: boolean
12+
inviteLink: string | null
13+
}
14+
15+
export function OrganizationAccessSettings({
16+
anonymousAccessEnabled,
17+
memberApprovalRequired,
18+
inviteLinkEnabled,
19+
inviteLink
20+
}: OrganizationAccessSettingsProps) {
21+
const [showInviteLink, setShowInviteLink] = useState(memberApprovalRequired && !anonymousAccessEnabled)
22+
const handleMemberApprovalToggle = (checked: boolean) => {
23+
setShowInviteLink(checked)
24+
}
25+
26+
return (
27+
<div className="space-y-6">
28+
<AnonymousAccessToggle
29+
anonymousAccessEnabled={anonymousAccessEnabled}
30+
/>
31+
32+
<div className={`transition-all duration-300 ease-in-out overflow-hidden max-h-96 opacity-100`}>
33+
<MemberApprovalRequiredToggle
34+
memberApprovalRequired={memberApprovalRequired}
35+
onToggleChange={handleMemberApprovalToggle}
36+
/>
37+
</div>
38+
39+
<div className={`transition-all duration-300 ease-in-out overflow-hidden ${
40+
showInviteLink
41+
? 'max-h-96 opacity-100'
42+
: 'max-h-0 opacity-0 pointer-events-none'
43+
}`}>
44+
<InviteLinkToggle
45+
inviteLinkEnabled={inviteLinkEnabled}
46+
inviteLink={inviteLink}
47+
/>
48+
</div>
49+
</div>
50+
)
51+
}

0 commit comments

Comments
 (0)