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:
parent
e17632405a
commit
a54b28bba8
5 changed files with 163 additions and 9 deletions
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
93
src/lib/authentik.ts
Normal 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();
|
||||||
|
|
@ -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 ?? "",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue