Enhance Discord integration by adding optional bot permissions in environment files and updating the dashboard to include an "Add Bot to your Server" button. Refactor authentication logic to handle token refresh and improve error handling for Discord sessions. Update placeholder text for better clarity in the search input field.
Some checks failed
CI / Bot (Python) (push) Successful in 12s
CI / Dashboard (Next.js) (push) Failing after 8s

This commit is contained in:
TheOnlyMace
2026-07-21 22:19:13 +02:00
parent 8294c1b4f7
commit 9006b06c09
10 changed files with 229 additions and 59 deletions

View File

@@ -26,6 +26,8 @@ JISHAKU_ENABLED=false
NEXTAUTH_SECRET=generate_a_long_random_secret_here
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# Optional: Discord permission bitfield for /api/invite (default: 8 = Administrator)
# DISCORD_BOT_PERMISSIONS=8
# Same Discord user IDs as owners/admins (comma-separated)
ADMIN_IDS=YOUR_DISCORD_USER_ID_HERE

View File

@@ -11,9 +11,10 @@ NEXT_PUBLIC_API_URL=http://127.0.0.1:8000/api/v1
NEXTAUTH_URL=http://localhost:3000/
NEXTAUTH_SECRET=generate_a_long_random_secret_here
# Discord OAuth configuration
# Discord OAuth configuration (also used by /api/invite for “Add to Server”)
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# DISCORD_BOT_PERMISSIONS=8
# Dashboard admin user IDs (comma separated — your Discord user ID)
ADMIN_IDS=YOUR_DISCORD_USER_ID_HERE

View File

@@ -0,0 +1,33 @@
/**
* ╔══════════════════════════════════════════════════════════════════╗
* ║ ║
* ║ +-+-+-+-+-+-+-+-+ ║
* ║ |H|e|x|a|H|o|s|t| ║
* ║ +-+-+-+-+-+-+-+-+ ║
* ║ ║
* ║ © 2026 HexaHost — All Rights Reserved ║
* ║ ║
* ║ discord ── https://discord.gg/hexahost ║
* ║ github ── https://github.com/theoneandonlymace ║
* ║ ║
* ╚══════════════════════════════════════════════════════════════════╝
*/
import { NextResponse } from "next/server";
import { getBotInviteUrl } from "@/lib/discord-invite";
export const dynamic = "force-dynamic";
/** Redirects to the Discord bot invite (uses DISCORD_CLIENT_ID at runtime). */
export async function GET() {
const url = getBotInviteUrl();
if (!url) {
return NextResponse.json(
{
detail:
"DISCORD_CLIENT_ID is not configured. Set it in the dashboard environment.",
},
{ status: 503 }
);
}
return NextResponse.redirect(url, 302);
}

View File

@@ -15,9 +15,10 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { Users, ShieldCheck, ChevronRight, Hash } from "lucide-react";
import { Users, ShieldCheck, ChevronRight, Hash, Plus, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { getBotInviteUrl } from "@/lib/discord-invite";
import { GuildSummary } from "@/types/api";
import { getServerSession } from "next-auth/next";
@@ -27,13 +28,29 @@ import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export const revalidate = 0;
function AddBotButton({ className }: { className?: string }) {
return (
<Button className={className} asChild>
<a href="/api/invite" target="_blank" rel="noopener noreferrer">
<Plus className="h-4 w-4 mr-2" />
Add Bot to your Server
<ExternalLink className="h-3.5 w-3.5 ml-2 opacity-60" />
</a>
</Button>
);
}
export default async function GuildsPage() {
const session = await getServerSession(authOptions);
if (!session || !session.accessToken) {
redirect("/");
}
if (session.error === "RefreshAccessTokenError" || session.error === "RefreshTokenMissing") {
redirect("/");
}
let botGuilds: GuildSummary[] = [];
let userGuilds: any[] = [];
let userDiscordError: string | null = null;
@@ -51,102 +68,117 @@ export default async function GuildsPage() {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
next: { revalidate: 300 } // Cache for 5 mins
cache: "no-store",
});
if (res.ok) {
userGuilds = await res.json();
} else {
userDiscordError = "Failed to fetch your Discord servers.";
const body = await res.text().catch(() => "");
console.error("Discord guilds fetch failed:", res.status, body);
userDiscordError =
res.status === 401
? "Discord session expired — please sign in again."
: "Failed to fetch your Discord servers.";
}
} catch (err) {
console.error("Discord API Error:", err);
userDiscordError = "Error connecting to Discord.";
}
// Filter out guilds that the user is an admin of (permission flag 0x8 or MANAGE_GUILD 0x20)
// Manage Server is 0x20, Admin is 0x8
const MANAGE_GUILD = BigInt(0x20);
const ADMINISTRATOR = BigInt(0x8);
const adminUserGuilds = userGuilds.filter(g => {
const adminUserGuilds = userGuilds.filter((g) => {
try {
const perms = BigInt(g.permissions);
return (perms & ADMINISTRATOR) === ADMINISTRATOR ||
(perms & MANAGE_GUILD) === MANAGE_GUILD ||
g.owner === true;
return (
(perms & ADMINISTRATOR) === ADMINISTRATOR ||
(perms & MANAGE_GUILD) === MANAGE_GUILD ||
g.owner === true
);
} catch {
return g.owner === true;
}
});
const adminGuildIds = new Set(adminUserGuilds.map(g => String(g.id)));
// The intersecting guilds we can manage
const guilds = botGuilds.filter(g => adminGuildIds.has(String(g.id)));
const error = botError || userDiscordError;
const adminGuildIds = new Set(adminUserGuilds.map((g) => String(g.id)));
const guilds = botGuilds.filter((g) => adminGuildIds.has(String(g.id)));
const inviteConfigured = Boolean(getBotInviteUrl());
// Hard failure only when bot API is down — Discord sync issues still show invite CTA
const hardError = botError;
return (
<div className="space-y-8">
<div className="flex justify-between items-end">
<div className="flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-end">
<div>
<h1 className="text-3xl font-bold text-white">Your Servers</h1>
<p className="text-slate-400 mt-2">
Select a server to manage its unique configuration and modules.
</p>
</div>
<div className="text-sm font-medium px-4 py-2 bg-slate-800 rounded-xl border border-slate-700 text-slate-300">
Showing <span className="text-white">{guilds.length}</span> active guilds
<div className="flex flex-wrap items-center gap-3">
{inviteConfigured && <AddBotButton className="shadow-lg shadow-primary/20" />}
<div className="text-sm font-medium px-4 py-2 bg-slate-800 rounded-xl border border-slate-700 text-slate-300">
Showing <span className="text-white">{guilds.length}</span> active guilds
</div>
</div>
</div>
{error ? (
{hardError ? (
<div className="bg-red-500/10 border border-red-500/20 p-8 rounded-2xl text-center">
<ShieldCheck className="h-12 w-12 text-red-500 mx-auto mb-4 opacity-50" />
<h3 className="text-white font-bold text-lg">Connection Error</h3>
<p className="text-slate-400 mt-2">{error}</p>
<Button variant="outline" className="mt-6">Retry Connection</Button>
<p className="text-slate-400 mt-2">{hardError}</p>
<div className="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="outline" asChild>
<Link href="/dashboard/guilds">Retry Connection</Link>
</Button>
{inviteConfigured && <AddBotButton />}
</div>
</div>
) : guilds.length === 0 ? (
<div className="bg-slate-800/30 border border-slate-800 border-dashed p-16 rounded-3xl text-center">
<div className="h-16 w-16 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Users className="h-8 w-8 text-slate-600" />
<div className="bg-slate-800/30 border border-slate-800 border-dashed p-12 md:p-16 rounded-3xl text-center">
<div className="h-16 w-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-6 border border-primary/20">
<Users className="h-8 w-8 text-primary" />
</div>
<h3 className="text-white font-bold text-xl">No Servers Found</h3>
<p className="text-slate-400 mt-2 max-w-sm mx-auto">
The bot hasn&apos;t joined any servers yet, or you don&apos;t have permission.
<p className="text-slate-400 mt-2 max-w-md mx-auto">
Invite Axiom to a Discord server where you have <span className="text-slate-200">Manage Server</span>{" "}
permissions, then come back here to configure it.
</p>
<div className="mt-8 bg-slate-900/50 p-4 rounded-xl text-left font-mono text-sm text-slate-300 max-w-2xl mx-auto overflow-auto max-h-48 whitespace-pre">
<p className="font-bold text-red-400 mb-2">Diagnostic Data:</p>
<p>1. Bot&apos;s Cache Total Servers: {botGuilds.length}</p>
<p>2. Your Discord Profile Total Servers: {userGuilds.length}</p>
<p>3. Your Discord Profile Admin/Manage Servers: {adminUserGuilds.length}</p>
{botGuilds.length > 0 && adminUserGuilds.length > 0 && (
<div className="mt-4 pt-4 border-t border-white/10">
<p className="text-emerald-400 mb-1">Bot First Server ID: {botGuilds[0].id} (Type: {typeof botGuilds[0].id})</p>
<p className="text-blue-400">Your First Admin Server ID: {adminUserGuilds[0].id} (Type: {typeof adminUserGuilds[0].id})</p>
</div>
{userDiscordError && (
<p className="text-amber-400/90 text-sm mt-4 max-w-md mx-auto">
Note: {userDiscordError}
</p>
)}
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center items-center">
{inviteConfigured ? (
<AddBotButton className="px-8 h-12 text-base shadow-lg shadow-primary/25" />
) : (
<p className="text-sm text-red-400">
Invite link not configured set <code className="text-xs">DISCORD_CLIENT_ID</code> in the
dashboard environment.
</p>
)}
<hr className="my-2 border-white/10" />
<p>Intersection Mappings found: {guilds.length}</p>
{userDiscordError && <p className="text-red-400">User Error: {userDiscordError}</p>}
{botError && <p className="text-red-400">Bot Error: {botError}</p>}
<Button variant="outline" asChild>
<Link href="/dashboard/guilds">Refresh list</Link>
</Button>
</div>
<Button className="mt-8">Invite to Discord</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{guilds.map((guild) => (
<div
key={guild.id}
<div
key={guild.id}
className="bg-[#141B2D] border border-slate-800 rounded-3xl group hover:border-primary/50 hover:bg-[#202c3f] transition-all duration-300 overflow-hidden shadow-sm hover:shadow-primary/5 shadow-black/20"
>
<div className="p-6">
<div className="flex items-start justify-between mb-6">
<div className="relative">
{guild.icon_url ? (
<Image
src={guild.icon_url}
<Image
src={guild.icon_url}
alt={guild.name}
width={64}
height={64}
@@ -157,11 +189,16 @@ export default async function GuildsPage() {
{guild.name.charAt(0)}
</div>
)}
<div className="absolute -bottom-1 -right-1 h-4 w-4 rounded-full bg-emerald-500 border-2 border-[#141B2D]" title="Bot Online" />
<div
className="absolute -bottom-1 -right-1 h-4 w-4 rounded-full bg-emerald-500 border-2 border-[#141B2D]"
title="Bot Online"
/>
</div>
<div className="flex flex-col items-end text-right">
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-widest mb-1">Guild ID</span>
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-widest mb-1">
Guild ID
</span>
<span className="text-xs font-mono text-slate-400 bg-black/20 px-2 py-1 rounded-lg border border-white/5 truncate max-w-[120px]">
{guild.id}
</span>
@@ -175,7 +212,9 @@ export default async function GuildsPage() {
<div className="flex items-center gap-4 mt-4 text-slate-400">
<div className="flex items-center gap-1.5 bg-slate-800/50 px-3 py-1.5 rounded-xl border border-white/5">
<Users className="h-4 w-4 text-slate-500" />
<span className="text-sm font-semibold text-slate-300">{guild.member_count.toLocaleString()}</span>
<span className="text-sm font-semibold text-slate-300">
{guild.member_count.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-1.5 bg-slate-800/50 px-3 py-1.5 rounded-xl border border-white/5">
<Hash className="h-4 w-4 text-slate-500" />
@@ -193,7 +232,6 @@ export default async function GuildsPage() {
</Link>
</Button>
</div>
</div>
))}
</div>

View File

@@ -345,7 +345,7 @@ export default function DashboardLayout({
<Search className="absolute left-4 h-4 w-4 text-slate-500 group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Query neural network..."
placeholder="Search Axiom..."
className="w-full bg-white/[0.03] border border-white/5 rounded-2xl py-2.5 pl-12 pr-4 text-xs font-bold text-slate-300 focus:outline-none focus:ring-1 focus:ring-primary/30 focus:bg-white/[0.05] transition-all placeholder:text-slate-600"
/>
</div>

View File

@@ -114,9 +114,11 @@ export default function LandingPage() {
<LayoutDashboard className="h-6 w-6 group-hover:rotate-12 transition-transform" />
Open Dashboard
</Button>
<Button variant="outline" className="w-full sm:w-auto rounded-2xl px-14 py-9 text-lg font-bold border-white/5 bg-white/[0.02] backdrop-blur-3xl hover:bg-white/[0.05] gap-3 text-white transition-all">
Add to Server
<ChevronRight className="h-5 w-5 opacity-40 group-hover:translate-x-1 transition-transform" />
<Button variant="outline" className="w-full sm:w-auto rounded-2xl px-14 py-9 text-lg font-bold border-white/5 bg-white/[0.02] backdrop-blur-3xl hover:bg-white/[0.05] gap-3 text-white transition-all" asChild>
<a href="/api/invite" target="_blank" rel="noopener noreferrer">
Add to Server
<ChevronRight className="h-5 w-5 opacity-40 group-hover:translate-x-1 transition-transform" />
</a>
</Button>
</div>
</div>

View File

@@ -15,6 +15,49 @@
import DiscordProvider from "next-auth/providers/discord";
import { AuthOptions } from "next-auth";
async function refreshDiscordAccessToken(token: {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
error?: string;
[key: string]: unknown;
}) {
if (!token.refreshToken) {
return { ...token, error: "RefreshTokenMissing" as const };
}
try {
const body = new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID || "",
client_secret: process.env.DISCORD_CLIENT_SECRET || "",
grant_type: "refresh_token",
refresh_token: String(token.refreshToken),
});
const res = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const data = await res.json();
if (!res.ok) {
throw data;
}
return {
...token,
accessToken: data.access_token as string,
refreshToken: (data.refresh_token as string) || token.refreshToken,
expiresAt: Math.floor(Date.now() / 1000) + Number(data.expires_in || 604800),
error: undefined,
};
} catch (error) {
console.error("Discord token refresh failed:", error);
return { ...token, error: "RefreshAccessTokenError" as const };
}
}
export const authOptions: AuthOptions = {
providers: [
DiscordProvider({
@@ -25,10 +68,28 @@ export const authOptions: AuthOptions = {
],
callbacks: {
async jwt({ token, account }) {
// Initial sign-in
if (account) {
token.accessToken = account.access_token;
return {
...token,
accessToken: account.access_token,
refreshToken: account.refresh_token,
expiresAt: account.expires_at,
};
}
return token;
const expiresAt = typeof token.expiresAt === "number" ? token.expiresAt : 0;
// Refresh ~60s before expiry
if (Date.now() < expiresAt * 1000 - 60_000) {
return token;
}
return refreshDiscordAccessToken(token as {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
error?: string;
});
},
async session({ session, token }) {
if (session.user) {
@@ -36,6 +97,8 @@ export const authOptions: AuthOptions = {
session.user.id = token.sub;
// @ts-ignore
session.accessToken = token.accessToken;
// @ts-ignore
session.error = token.error;
}
return session;
},

View File

@@ -0,0 +1,21 @@
/**
* Discord bot invite URL helpers.
*/
const DEFAULT_PERMISSIONS = "8"; // Administrator (Discord invite UI still shows the checklist)
export function getBotInviteUrl(clientId?: string | null): string | null {
const id =
(clientId || "").trim() ||
(process.env.DISCORD_CLIENT_ID || "").trim() ||
(process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID || "").trim();
if (!id) return null;
const params = new URLSearchParams({
client_id: id,
permissions: process.env.DISCORD_BOT_PERMISSIONS?.trim() || DEFAULT_PERMISSIONS,
scope: "bot applications.commands",
});
return `https://discord.com/oauth2/authorize?${params.toString()}`;
}

File diff suppressed because one or more lines are too long

View File

@@ -17,8 +17,18 @@ import NextAuth, { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
accessToken?: string;
error?: string;
user: {
id: string;
} & DefaultSession["user"];
}
}
declare module "next-auth/jwt" {
interface JWT {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
error?: string;
}
}