feat: Integrate Authentik for account creation

Add support for creating Authentik invitations after successful
whitelisting.
The invite URL is now stored in local storage and displayed to the user.
Refactor Bedrock profile fetching to use a new action.
This commit is contained in:
anonymousratwastaken 2026-03-15 17:48:02 +00:00
parent e17632405a
commit a54b28bba8
5 changed files with 163 additions and 9 deletions

View file

@ -15,7 +15,7 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { cfg } from "@/lib/cfg"; import { cfg } from "@/lib/cfg";
import { getAvatarProfile } from "@/lib/avatar"; import { verifyAccessCode, getBedrockProfile } from "@/app/actions";
import localFont from "next/font/local"; import localFont from "next/font/local";
@ -42,6 +42,7 @@ export default function AccessPage({ params }: PageProps) {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [verified, setVerified] = useState(false); const [verified, setVerified] = useState(false);
const [whitelistSuccess, setWhitelistSuccess] = useState(false); const [whitelistSuccess, setWhitelistSuccess] = useState(false);
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [status, setStatus] = useState<{ ok: boolean; message?: string }>({ ok: false }); const [status, setStatus] = useState<{ ok: boolean; message?: string }>({ ok: false });
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -102,6 +103,7 @@ export default function AccessPage({ params }: PageProps) {
const savedVerified = localStorage.getItem(`${prefix}verified`); const savedVerified = localStorage.getItem(`${prefix}verified`);
const savedWhitelistSuccess = localStorage.getItem(`${prefix}whitelist-success`); const savedWhitelistSuccess = localStorage.getItem(`${prefix}whitelist-success`);
const savedInviteUrl = localStorage.getItem(`${prefix}invite-url`);
const savedCode = localStorage.getItem(`${prefix}auth-code`); const savedCode = localStorage.getItem(`${prefix}auth-code`);
const savedJavaName = localStorage.getItem(`${prefix}java-name`); const savedJavaName = localStorage.getItem(`${prefix}java-name`);
const savedBedrockName = localStorage.getItem(`${prefix}bedrock-name`); const savedBedrockName = localStorage.getItem(`${prefix}bedrock-name`);
@ -115,6 +117,9 @@ export default function AccessPage({ params }: PageProps) {
if (savedWhitelistSuccess === 'true') { if (savedWhitelistSuccess === 'true') {
setWhitelistSuccess(true); setWhitelistSuccess(true);
} }
if (savedInviteUrl) {
setInviteUrl(savedInviteUrl);
}
if (savedCode) { if (savedCode) {
setCode(savedCode); setCode(savedCode);
} }
@ -154,6 +159,11 @@ export default function AccessPage({ params }: PageProps) {
localStorage.setItem(`${prefix}whitelist-success`, whitelistSuccess.toString()); localStorage.setItem(`${prefix}whitelist-success`, whitelistSuccess.toString());
}, [whitelistSuccess, method]); }, [whitelistSuccess, method]);
useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}invite-url`, inviteUrl || "");
}, [inviteUrl, method]);
useEffect(() => { useEffect(() => {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-'; const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.setItem(`${prefix}auth-code`, code); localStorage.setItem(`${prefix}auth-code`, code);
@ -200,11 +210,11 @@ export default function AccessPage({ params }: PageProps) {
const fetchProfileData = async () => { const fetchProfileData = async () => {
try { try {
const res = await fetch( const data = await getBedrockProfile(cfg.server.bedrockFriend);
`/api/xbox/profile/get?gamertag=${encodeURIComponent(cfg.server.bedrockFriend)}` setProfileData({
); avatarUrl: data.avatarUrl,
const data = await res.json(); gamertag: data.gamertag
setProfileData(data); });
} catch (e) { } catch (e) {
console.error("Failed to fetch profile", e); console.error("Failed to fetch profile", e);
} }
@ -330,6 +340,11 @@ export default function AccessPage({ params }: PageProps) {
}); });
setWhitelistSuccess(true); setWhitelistSuccess(true);
// If Authentik invite URL is provided, store it
if (result.inviteUrl) {
setInviteUrl(result.inviteUrl);
}
// Navigate to success step // Navigate to success step
router.push(`/access/${method}/success`); router.push(`/access/${method}/success`);
} else { } else {
@ -400,6 +415,7 @@ export default function AccessPage({ params }: PageProps) {
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-'; const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
localStorage.removeItem(`${prefix}verified`); localStorage.removeItem(`${prefix}verified`);
localStorage.removeItem(`${prefix}whitelist-success`); localStorage.removeItem(`${prefix}whitelist-success`);
localStorage.removeItem(`${prefix}invite-url`);
localStorage.removeItem(`${prefix}auth-code`); localStorage.removeItem(`${prefix}auth-code`);
localStorage.removeItem(`${prefix}java-name`); localStorage.removeItem(`${prefix}java-name`);
localStorage.removeItem(`${prefix}bedrock-name`); localStorage.removeItem(`${prefix}bedrock-name`);
@ -409,6 +425,7 @@ export default function AccessPage({ params }: PageProps) {
setVerified(false); setVerified(false);
setWhitelistSuccess(false); setWhitelistSuccess(false);
setInviteUrl(null);
setCode(""); setCode("");
setJavaName(""); setJavaName("");
setBedrockName(""); setBedrockName("");
@ -733,16 +750,16 @@ export default function AccessPage({ params }: PageProps) {
<Item variant="outline"> <Item variant="outline">
<ItemMedia> <ItemMedia>
<Avatar size="lg"> <Avatar size="lg">
<AvatarImage src={cfg.server.bedrockFriendAvatar} /> <AvatarImage src={profileData?.avatarUrl || cfg.server.bedrockFriendAvatar} />
<AvatarFallback>?</AvatarFallback> <AvatarFallback>?</AvatarFallback>
</Avatar> </Avatar>
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{cfg.server.bedrockFriend}</ItemTitle> <ItemTitle>{profileData?.gamertag || cfg.server.bedrockFriend}</ItemTitle>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<Button asChild> <Button asChild>
<Link href={`https://www.xbox.com/en-GB/play/user/${cfg.server.bedrockFriend}`} target="_blank"> <Link href={`https://www.xbox.com/en-GB/play/user/${profileData?.gamertag || cfg.server.bedrockFriend}`} target="_blank">
<ExternalLink className="w-4 h-4 mr-2" /> <ExternalLink className="w-4 h-4 mr-2" />
View View
</Link> </Link>
@ -753,6 +770,19 @@ export default function AccessPage({ params }: PageProps) {
{/* Actions */} {/* Actions */}
<div className="space-y-3"> <div className="space-y-3">
{inviteUrl && (
<div className="space-y-2">
<Button asChild className="w-full bg-blue-600 hover:bg-blue-700">
<a href={inviteUrl}>
<ExternalLink className="w-4 h-4 mr-2" />
Create an account (optional)
</a>
</Button>
<p className="text-xs text-center text-muted-foreground">
An account lets you manage your profile and access more features.
</p>
</div>
)}
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link href={`/access/${method}/survey`}> <Link href={`/access/${method}/survey`}>
<Gift className="w-4 h-4 mr-2" /> <Gift className="w-4 h-4 mr-2" />

View file

@ -5,6 +5,12 @@ type VerificationResult = {
message?: string; message?: string;
}; };
import { getAvatarProfile, AvatarProfile } from "@/lib/avatar";
export async function getBedrockProfile(gamertag: string): Promise<AvatarProfile> {
return getAvatarProfile(gamertag);
}
const normalizeCode = (value: string) => const normalizeCode = (value: string) =>
value value
.toUpperCase() .toUpperCase()

View file

@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { servertap } from "@/lib/servertap"; import { servertap } from "@/lib/servertap";
import { minecraftApi } from "@/lib/minecraft-api"; import { minecraftApi } from "@/lib/minecraft-api";
import { verifyAccessCode } from "@/app/actions"; import { verifyAccessCode } from "@/app/actions";
import { authentik } from "@/lib/authentik";
type WhitelistRequest = { type WhitelistRequest = {
javaName?: string; javaName?: string;
@ -94,10 +95,29 @@ export async function POST(request: Request) {
} }
} }
// After successful whitelisting, create Authentik invitation
let inviteUrl: string | undefined;
if (errors.length === 0 && resolvedPlayers.length > 0) {
try {
// Use the first resolved player's username for the invitation
// If there are multiple, Java is usually preferred as primary
const primaryPlayer = resolvedPlayers.find(p => p.platform === 'java') || resolvedPlayers[0];
const inviteResult = await authentik.createInvitation(primaryPlayer.username);
if (inviteResult.success) {
inviteUrl = inviteResult.inviteUrl;
} else {
console.error("Failed to create Authentik invitation:", inviteResult.error);
}
} catch (inviteError) {
console.error("Error in Authentik invitation flow:", inviteError);
}
}
return NextResponse.json({ return NextResponse.json({
success: errors.length === 0, success: errors.length === 0,
results, results,
errors, errors,
inviteUrl,
}); });
} catch (error) { } catch (error) {
console.error("Failed to process whitelist request", error); console.error("Failed to process whitelist request", error);

93
src/lib/authentik.ts Normal file
View file

@ -0,0 +1,93 @@
import { cfg } from "./cfg";
export type AuthentikInvitationResponse = {
pk: string;
name: string;
expires: string;
flow: string;
flow_obj: any;
created: string;
fixed_data: Record<string, any>;
single_use: boolean;
};
class AuthentikClient {
private token: string;
private flowUuid: string;
private flowSlug: string;
constructor() {
this.token = cfg.authentik.token || "";
this.flowUuid = cfg.authentik.enrollmentFlowUuid || "";
this.flowSlug = cfg.authentik.enrollmentFlowSlug || "";
}
async createInvitation(username: string): Promise<{ success: boolean; inviteUrl?: string; error?: string }> {
if (!this.token || !this.flowUuid || !this.flowSlug) {
console.error("Authentik configuration is missing");
return { success: false, error: "Authentik configuration is missing" };
}
try {
// Set expiration to 24 hours from now
const expires = new Date();
expires.setHours(expires.getHours() + 24);
const safeName = `whitelist_${username.toLowerCase().replace(/[^a-z0-9_-]/g, "_")}`;
console.log(`Creating Authentik invitation for ${username} to flow ${this.flowUuid}`);
const response = await fetch("https://auth.frg.network/api/v3/stages/invitation/invitations/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.token}`,
},
body: JSON.stringify({
name: safeName,
expires: expires.toISOString(),
single_use: true,
flow: this.flowUuid,
fixed_data: {
username: username,
},
}),
});
if (!response.ok) {
let errorDetail = "";
try {
const errorData = await response.json();
errorDetail = JSON.stringify(errorData);
} catch (e) {
errorDetail = await response.text().catch(() => "Unknown error");
}
console.error(`Failed to create Authentik invitation: ${response.status} ${response.statusText}`, errorDetail);
return {
success: false,
error: `Authentik API error: ${response.status} ${response.statusText}`
};
}
const data = (await response.json()) as AuthentikInvitationResponse;
// Construct the invite URL
// Format: https://auth.frg.network/if/flow/{enrollment-flow-slug}/?itoken={pk}
const inviteUrl = `https://auth.frg.network/if/flow/${this.flowSlug}/?itoken=${data.pk}`;
return {
success: true,
inviteUrl,
};
} catch (error) {
console.error("Error creating Authentik invitation:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
export const authentik = new AuthentikClient();

View file

@ -20,6 +20,11 @@ export const cfg = {
url: process.env.SERVERTAP_BASE_URL, url: process.env.SERVERTAP_BASE_URL,
key: process.env.SERVERTAP_API_KEY, key: process.env.SERVERTAP_API_KEY,
}, },
authentik: {
token: process.env.AUTHENTIK_API_TOKEN,
enrollmentFlowUuid: process.env.AUTHENTIK_ENROLLMENT_FLOW_UUID,
enrollmentFlowSlug: process.env.AUTHENTIK_ENROLLMENT_FLOW_SLUG,
},
server: { server: {
javaIp: process.env.NEXT_PUBLIC_SERVER_JAVA_IP ?? "", javaIp: process.env.NEXT_PUBLIC_SERVER_JAVA_IP ?? "",
bedrockIp: process.env.NEXT_PUBLIC_SERVER_BEDROCK_IP ?? "", bedrockIp: process.env.NEXT_PUBLIC_SERVER_BEDROCK_IP ?? "",