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 { useRouter } from "next/navigation";
|
||||
import { cfg } from "@/lib/cfg";
|
||||
import { getAvatarProfile } from "@/lib/avatar";
|
||||
import { verifyAccessCode, getBedrockProfile } from "@/app/actions";
|
||||
|
||||
import localFont from "next/font/local";
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export default function AccessPage({ params }: PageProps) {
|
|||
const [code, setCode] = useState("");
|
||||
const [verified, setVerified] = 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 [isPending, startTransition] = useTransition();
|
||||
|
||||
|
|
@ -102,6 +103,7 @@ export default function AccessPage({ params }: PageProps) {
|
|||
|
||||
const savedVerified = localStorage.getItem(`${prefix}verified`);
|
||||
const savedWhitelistSuccess = localStorage.getItem(`${prefix}whitelist-success`);
|
||||
const savedInviteUrl = localStorage.getItem(`${prefix}invite-url`);
|
||||
const savedCode = localStorage.getItem(`${prefix}auth-code`);
|
||||
const savedJavaName = localStorage.getItem(`${prefix}java-name`);
|
||||
const savedBedrockName = localStorage.getItem(`${prefix}bedrock-name`);
|
||||
|
|
@ -115,6 +117,9 @@ export default function AccessPage({ params }: PageProps) {
|
|||
if (savedWhitelistSuccess === 'true') {
|
||||
setWhitelistSuccess(true);
|
||||
}
|
||||
if (savedInviteUrl) {
|
||||
setInviteUrl(savedInviteUrl);
|
||||
}
|
||||
if (savedCode) {
|
||||
setCode(savedCode);
|
||||
}
|
||||
|
|
@ -154,6 +159,11 @@ export default function AccessPage({ params }: PageProps) {
|
|||
localStorage.setItem(`${prefix}whitelist-success`, whitelistSuccess.toString());
|
||||
}, [whitelistSuccess, method]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
|
||||
localStorage.setItem(`${prefix}invite-url`, inviteUrl || "");
|
||||
}, [inviteUrl, method]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
|
||||
localStorage.setItem(`${prefix}auth-code`, code);
|
||||
|
|
@ -200,11 +210,11 @@ export default function AccessPage({ params }: PageProps) {
|
|||
|
||||
const fetchProfileData = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/xbox/profile/get?gamertag=${encodeURIComponent(cfg.server.bedrockFriend)}`
|
||||
);
|
||||
const data = await res.json();
|
||||
setProfileData(data);
|
||||
const data = await getBedrockProfile(cfg.server.bedrockFriend);
|
||||
setProfileData({
|
||||
avatarUrl: data.avatarUrl,
|
||||
gamertag: data.gamertag
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch profile", e);
|
||||
}
|
||||
|
|
@ -330,6 +340,11 @@ export default function AccessPage({ params }: PageProps) {
|
|||
});
|
||||
setWhitelistSuccess(true);
|
||||
|
||||
// If Authentik invite URL is provided, store it
|
||||
if (result.inviteUrl) {
|
||||
setInviteUrl(result.inviteUrl);
|
||||
}
|
||||
|
||||
// Navigate to success step
|
||||
router.push(`/access/${method}/success`);
|
||||
} else {
|
||||
|
|
@ -400,6 +415,7 @@ export default function AccessPage({ params }: PageProps) {
|
|||
const prefix = method === 'qr' ? 'frg-qr-' : 'frg-';
|
||||
localStorage.removeItem(`${prefix}verified`);
|
||||
localStorage.removeItem(`${prefix}whitelist-success`);
|
||||
localStorage.removeItem(`${prefix}invite-url`);
|
||||
localStorage.removeItem(`${prefix}auth-code`);
|
||||
localStorage.removeItem(`${prefix}java-name`);
|
||||
localStorage.removeItem(`${prefix}bedrock-name`);
|
||||
|
|
@ -409,6 +425,7 @@ export default function AccessPage({ params }: PageProps) {
|
|||
|
||||
setVerified(false);
|
||||
setWhitelistSuccess(false);
|
||||
setInviteUrl(null);
|
||||
setCode("");
|
||||
setJavaName("");
|
||||
setBedrockName("");
|
||||
|
|
@ -733,16 +750,16 @@ export default function AccessPage({ params }: PageProps) {
|
|||
<Item variant="outline">
|
||||
<ItemMedia>
|
||||
<Avatar size="lg">
|
||||
<AvatarImage src={cfg.server.bedrockFriendAvatar} />
|
||||
<AvatarImage src={profileData?.avatarUrl || cfg.server.bedrockFriendAvatar} />
|
||||
<AvatarFallback>?</AvatarFallback>
|
||||
</Avatar>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{cfg.server.bedrockFriend}</ItemTitle>
|
||||
<ItemTitle>{profileData?.gamertag || cfg.server.bedrockFriend}</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<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" />
|
||||
View
|
||||
</Link>
|
||||
|
|
@ -753,6 +770,19 @@ export default function AccessPage({ params }: PageProps) {
|
|||
|
||||
{/* Actions */}
|
||||
<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">
|
||||
<Link href={`/access/${method}/survey`}>
|
||||
<Gift className="w-4 h-4 mr-2" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ type VerificationResult = {
|
|||
message?: string;
|
||||
};
|
||||
|
||||
import { getAvatarProfile, AvatarProfile } from "@/lib/avatar";
|
||||
|
||||
export async function getBedrockProfile(gamertag: string): Promise<AvatarProfile> {
|
||||
return getAvatarProfile(gamertag);
|
||||
}
|
||||
|
||||
const normalizeCode = (value: string) =>
|
||||
value
|
||||
.toUpperCase()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||
import { servertap } from "@/lib/servertap";
|
||||
import { minecraftApi } from "@/lib/minecraft-api";
|
||||
import { verifyAccessCode } from "@/app/actions";
|
||||
import { authentik } from "@/lib/authentik";
|
||||
|
||||
type WhitelistRequest = {
|
||||
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({
|
||||
success: errors.length === 0,
|
||||
results,
|
||||
errors,
|
||||
inviteUrl,
|
||||
});
|
||||
} catch (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,
|
||||
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: {
|
||||
javaIp: process.env.NEXT_PUBLIC_SERVER_JAVA_IP ?? "",
|
||||
bedrockIp: process.env.NEXT_PUBLIC_SERVER_BEDROCK_IP ?? "",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue