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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
33
dashboard/app/api/invite/route.ts
Normal file
33
dashboard/app/api/invite/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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,6 +28,18 @@ 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);
|
||||
|
||||
@@ -34,6 +47,10 @@ export default async function GuildsPage() {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
if (session.error === "RefreshAccessTokenError" || session.error === "RefreshTokenMissing") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
let botGuilds: GuildSummary[] = [];
|
||||
let userGuilds: any[] = [];
|
||||
let userDiscordError: string | null = null;
|
||||
@@ -51,88 +68,103 @@ 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't joined any servers yet, or you don'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'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">
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
21
dashboard/lib/discord-invite.ts
Normal file
21
dashboard/lib/discord-invite.ts
Normal 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
10
dashboard/types/next-auth.d.ts
vendored
10
dashboard/types/next-auth.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user