Enhance dashboard guild access with owner bypass functionality

- Introduced `resolveDashboardGuild` to handle guild resolution for both managed and owner-panel bypass scenarios.
- Updated `GuildLayout` and `WelcomePage` components to utilize the new guild resolution logic, improving access handling.
- Enhanced `DashboardShell` to display an informational banner for users accessing guilds via the owner-panel bypass.
- Added localization support for owner bypass messages in English and German.
- Updated authentication logic to allow owner-panel users to access guilds without Discord Manage Guild permission.
This commit is contained in:
smueller
2026-07-24 11:59:16 +02:00
parent e45642b6f9
commit 20593b7173
10 changed files with 133 additions and 22 deletions

View File

@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { DashboardShell } from '@/components/layout/dashboard-shell'; import { DashboardShell } from '@/components/layout/dashboard-shell';
import { requireGuildAccessOrRedirect } from '@/lib/auth'; import { requireGuildAccessOrRedirect } from '@/lib/auth';
import { getManageableGuilds } from '@/lib/guilds'; import { getManageableGuilds, resolveDashboardGuild } from '@/lib/guilds';
import { isOwnerUser } from '@/lib/owner-auth'; import { isOwnerUser } from '@/lib/owner-auth';
interface GuildLayoutProps { interface GuildLayoutProps {
@@ -13,23 +13,26 @@ interface GuildLayoutProps {
export default async function GuildLayout({ children, params }: GuildLayoutProps) { export default async function GuildLayout({ children, params }: GuildLayoutProps) {
const { guildId } = await params; const { guildId } = await params;
const session = await requireGuildAccessOrRedirect(guildId); const session = await requireGuildAccessOrRedirect(guildId);
const guilds = await getManageableGuilds(session); const [resolved, guilds, showOwnerLink] = await Promise.all([
const currentGuild = guilds.find((guild) => guild.id === guildId); resolveDashboardGuild(session, guildId),
getManageableGuilds(session),
isOwnerUser(session.user.id)
]);
if (!currentGuild) { if (!resolved) {
notFound(); notFound();
} }
const otherGuilds = guilds.filter((guild) => guild.id !== guildId); const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
const showOwnerLink = await isOwnerUser(session.user.id);
return ( return (
<DashboardShell <DashboardShell
guildId={guildId} guildId={guildId}
currentGuild={currentGuild} currentGuild={resolved.guild}
otherGuilds={otherGuilds} otherGuilds={otherGuilds}
user={session.user} user={session.user}
showOwnerLink={showOwnerLink} showOwnerLink={showOwnerLink}
ownerBypass={resolved.ownerBypass}
> >
{children} {children}
</DashboardShell> </DashboardShell>

View File

@@ -1,7 +1,7 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { WelcomeForm } from '@/components/modules/welcome-form'; import { WelcomeForm } from '@/components/modules/welcome-form';
import { requireGuildAccessOrRedirect } from '@/lib/auth'; import { requireGuildAccessOrRedirect } from '@/lib/auth';
import { getManageableGuild } from '@/lib/guilds'; import { resolveDashboardGuild } from '@/lib/guilds';
import { getLocale, t } from '@/lib/i18n'; import { getLocale, t } from '@/lib/i18n';
import { getWelcomeDashboard } from '@/lib/module-configs/welcome'; import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars'; import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars';
@@ -13,13 +13,13 @@ interface WelcomePageProps {
export default async function WelcomePage({ params }: WelcomePageProps) { export default async function WelcomePage({ params }: WelcomePageProps) {
const { guildId } = await params; const { guildId } = await params;
const session = await requireGuildAccessOrRedirect(guildId); const session = await requireGuildAccessOrRedirect(guildId);
const [locale, config, guild] = await Promise.all([ const [locale, config, resolved] = await Promise.all([
getLocale(), getLocale(),
getWelcomeDashboard(guildId), getWelcomeDashboard(guildId),
getManageableGuild(session, guildId) resolveDashboardGuild(session, guildId)
]); ]);
if (!guild) { if (!resolved) {
notFound(); notFound();
} }
@@ -32,7 +32,7 @@ export default async function WelcomePage({ params }: WelcomePageProps) {
<WelcomeForm <WelcomeForm
guildId={guildId} guildId={guildId}
initialValue={config} initialValue={config}
previewVars={buildWelcomePreviewVars(session.user, guild)} previewVars={buildWelcomePreviewVars(session.user, resolved.guild)}
/> />
</div> </div>
); );

View File

@@ -18,6 +18,8 @@ interface DashboardShellProps {
otherGuilds: DashboardGuild[]; otherGuilds: DashboardGuild[];
user: SessionUser; user: SessionUser;
showOwnerLink?: boolean; showOwnerLink?: boolean;
/** True when an Owner-panel user opened a guild they do not manage on Discord. */
ownerBypass?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -27,6 +29,7 @@ export function DashboardShell({
otherGuilds, otherGuilds,
user, user,
showOwnerLink = false, showOwnerLink = false,
ownerBypass = false,
children children
}: DashboardShellProps) { }: DashboardShellProps) {
const t = useTranslations(); const t = useTranslations();
@@ -59,7 +62,21 @@ export function DashboardShell({
</div> </div>
</header> </header>
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8"> <main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div> <div className="mx-auto w-full max-w-[1200px] space-y-4">
{ownerBypass ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
<p className="font-medium">{t('dashboard.ownerBypass.title')}</p>
<p className="mt-1 text-amber-100/80">{t('dashboard.ownerBypass.description')}</p>
<a
href="/owner/guilds"
className="mt-2 inline-block text-sm font-medium text-amber-50 underline underline-offset-4 hover:text-white"
>
{t('dashboard.ownerBypass.backToOwner')}
</a>
</div>
) : null}
{children}
</div>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useMemo, useState, useTransition } from 'react'; import { useMemo, useState, useTransition } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -165,6 +166,9 @@ export function GuildsManager({
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" variant="default" asChild>
<Link href={`/dashboard/${guild.id}`}>{t('owner.guilds.openDashboard')}</Link>
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"

View File

@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { hasManageGuildPermission } from '@nexumi/shared'; import { hasManageGuildPermission } from '@nexumi/shared';
import { fetchDiscordUserGuildsCached } from './discord-oauth'; import { fetchDiscordUserGuildsCached } from './discord-oauth';
import { hasOwnerGuildBypass } from './owner-auth';
import { prisma } from './prisma'; import { prisma } from './prisma';
import { getSession, type SessionPayload } from './session'; import { getSession, type SessionPayload } from './session';
@@ -46,19 +47,25 @@ export async function requireAuthOrRedirect(): Promise<SessionPayload> {
/** /**
* Ensures the current session user has Manage Guild permission on the given * Ensures the current session user has Manage Guild permission on the given
* guild (via Discord OAuth guild list) and that the bot is actually installed * guild (via Discord OAuth guild list) and that the bot is actually installed
* there (tracked via the `Guild` table). Use in API routes. * there (tracked via the `Guild` table). Owner-panel users bypass Discord
* membership checks. Use in API routes.
*/ */
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> { export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
const session = await requireAuth(); const session = await requireAuth();
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
if (!dbGuild) {
throw new ForbiddenError('Nexumi is not installed on this server');
}
if (await hasOwnerGuildBypass(session.user.id)) {
return session;
}
const guilds = await fetchDiscordUserGuildsCached(session.accessToken); const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId); const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) { if (!guild || !hasManageGuildPermission(guild.permissions)) {
throw new ForbiddenError('Missing Manage Guild permission for this server'); throw new ForbiddenError('Missing Manage Guild permission for this server');
} }
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
if (!dbGuild) {
throw new ForbiddenError('Nexumi is not installed on this server');
}
return session; return session;
} }
@@ -68,15 +75,20 @@ export async function requireGuildAccess(guildId: string): Promise<SessionPayloa
*/ */
export async function requireGuildAccessOrRedirect(guildId: string): Promise<SessionPayload> { export async function requireGuildAccessOrRedirect(guildId: string): Promise<SessionPayload> {
const session = await requireAuthOrRedirect(); const session = await requireAuthOrRedirect();
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
if (!dbGuild) {
redirect('/dashboard');
}
if (await hasOwnerGuildBypass(session.user.id)) {
return session;
}
const guilds = await fetchDiscordUserGuildsCached(session.accessToken); const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId); const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) { if (!guild || !hasManageGuildPermission(guild.permissions)) {
redirect('/dashboard'); redirect('/dashboard');
} }
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
if (!dbGuild) {
redirect('/dashboard');
}
return session; return session;
} }

View File

@@ -1,8 +1,13 @@
import { hasManageGuildPermission, type DashboardGuild } from '@nexumi/shared'; import { hasManageGuildPermission, type DashboardGuild } from '@nexumi/shared';
import { fetchDiscordUserGuildsCached } from './discord-oauth'; import { fetchDiscordUserGuildsCached } from './discord-oauth';
import { hasOwnerGuildBypass } from './owner-auth';
import { getOwnerGuildDiscordMeta } from './owner-guilds';
import { prisma } from './prisma'; import { prisma } from './prisma';
import type { SessionPayload } from './session'; import type { SessionPayload } from './session';
/** Administrator bit — used for synthetic owner-bypass guild entries. */
const ADMINISTRATOR_PERMISSION = '8';
/** /**
* Returns the guilds the current session user can manage (Manage Guild * Returns the guilds the current session user can manage (Manage Guild
* permission), enriched with whether Nexumi is already installed there. * permission), enriched with whether Nexumi is already installed there.
@@ -40,7 +45,7 @@ export async function getManageableGuilds(session: SessionPayload): Promise<Dash
/** /**
* Looks up a single manageable guild by id, or null when the session user * Looks up a single manageable guild by id, or null when the session user
* does not manage it. * does not manage it via Discord OAuth.
*/ */
export async function getManageableGuild( export async function getManageableGuild(
session: SessionPayload, session: SessionPayload,
@@ -49,3 +54,39 @@ export async function getManageableGuild(
const guilds = await getManageableGuilds(session); const guilds = await getManageableGuilds(session);
return guilds.find((guild) => guild.id === guildId) ?? null; return guilds.find((guild) => guild.id === guildId) ?? null;
} }
/**
* Resolves a guild for the dashboard shell: normal Manage Guild membership,
* or owner-panel bypass via Discord bot guild metadata when the bot is
* installed but the user is not a Discord manager of that server.
*/
export async function resolveDashboardGuild(
session: SessionPayload,
guildId: string
): Promise<{ guild: DashboardGuild; ownerBypass: boolean } | null> {
const managed = await getManageableGuild(session, guildId);
if (managed?.botPresent) {
return { guild: managed, ownerBypass: false };
}
if (!(await hasOwnerGuildBypass(session.user.id))) {
return managed ? { guild: managed, ownerBypass: false } : null;
}
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
if (!dbGuild) {
return null;
}
const meta = await getOwnerGuildDiscordMeta(guildId);
return {
ownerBypass: true,
guild: {
id: guildId,
name: meta?.name ?? `Guild ${guildId}`,
icon: meta?.icon ?? null,
botPresent: true,
permissions: ADMINISTRATOR_PERMISSION
}
};
}

View File

@@ -31,6 +31,14 @@ export async function isOwnerUser(userId: string): Promise<boolean> {
return (await resolveOwnerRole(userId)) !== null; return (await resolveOwnerRole(userId)) !== null;
} }
/**
* Owner-panel team members (any role) may open every guild where the bot is
* installed — for support without needing Manage Guild on Discord.
*/
export async function hasOwnerGuildBypass(userId: string): Promise<boolean> {
return (await resolveOwnerRole(userId)) !== null;
}
export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> { export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
const session = await requireAuth(); const session = await requireAuth();
const role = await resolveOwnerRole(session.user.id); const role = await resolveOwnerRole(session.user.id);

View File

@@ -273,6 +273,11 @@
"botMissing": "Nicht installiert", "botMissing": "Nicht installiert",
"empty": "Keine Server gefunden. Du brauchst die Berechtigung „Server verwalten“ auf einem Discord-Server, um hier zu erscheinen." "empty": "Keine Server gefunden. Du brauchst die Berechtigung „Server verwalten“ auf einem Discord-Server, um hier zu erscheinen."
}, },
"ownerBypass": {
"title": "Owner-Zugriff",
"description": "Du bearbeitest diesen Server über das Owner-Panel, ohne Discord-Mitglied mit „Server verwalten“ zu sein.",
"backToOwner": "Zurück zur Server-Verwaltung"
},
"overview": { "overview": {
"title": "Übersicht", "title": "Übersicht",
"moduleStatusTitle": "Modul-Status", "moduleStatusTitle": "Modul-Status",
@@ -997,6 +1002,7 @@
"createInvite": "Invite erstellen", "createInvite": "Invite erstellen",
"inviteCopied": "Invite kopiert: {url}", "inviteCopied": "Invite kopiert: {url}",
"inviteCreated": "Invite erstellt: {url}", "inviteCreated": "Invite erstellt: {url}",
"openDashboard": "Dashboard öffnen",
"blacklisted": "Blacklist", "blacklisted": "Blacklist",
"blacklist": "Blacklisten", "blacklist": "Blacklisten",
"unblacklist": "Blacklist entfernen", "unblacklist": "Blacklist entfernen",

View File

@@ -273,6 +273,11 @@
"botMissing": "Not installed", "botMissing": "Not installed",
"empty": "No servers found. You need the Manage Server permission on a Discord server to appear here." "empty": "No servers found. You need the Manage Server permission on a Discord server to appear here."
}, },
"ownerBypass": {
"title": "Owner access",
"description": "You are editing this server via the Owner panel without Discord Manage Server permission.",
"backToOwner": "Back to guild management"
},
"overview": { "overview": {
"title": "Overview", "title": "Overview",
"moduleStatusTitle": "Module status", "moduleStatusTitle": "Module status",
@@ -997,6 +1002,7 @@
"createInvite": "Create invite", "createInvite": "Create invite",
"inviteCopied": "Invite copied: {url}", "inviteCopied": "Invite copied: {url}",
"inviteCreated": "Invite created: {url}", "inviteCreated": "Invite created: {url}",
"openDashboard": "Open dashboard",
"blacklisted": "Blacklisted", "blacklisted": "Blacklisted",
"blacklist": "Blacklist", "blacklist": "Blacklist",
"unblacklist": "Remove blacklist", "unblacklist": "Remove blacklist",

View File

@@ -320,6 +320,20 @@
- [ ] Suche nach Servername filtert - [ ] Suche nach Servername filtert
- [ ] Invite erstellen → Link im Toast/Clipboard; Bot braucht Create Instant Invite - [ ] Invite erstellen → Link im Toast/Clipboard; Bot braucht Create Instant Invite
## Post-Phase Owner Guild-Dashboard Bypass (Status: implementiert)
### Abgeschlossen (Code)
- Owner-Team (jede Owner-Rolle) kann jedes Guild-Dashboard öffnen, in dem der Bot installiert ist — ohne Discord „Server verwalten“
- `requireGuildAccess` / Layout nutzen Owner-Bypass; Guild-Meta via Bot-API wenn nicht in OAuth-Liste
- Owner `/owner/guilds`: Button „Dashboard öffnen“; Hinweisbanner im Guild-Dashboard bei Bypass
### Manuell testen
- [ ] Owner-UI → Server → Dashboard öffnen (Server, auf dem man kein Member/Manager ist)
- [ ] Welcome/Settings speichern funktioniert; Banner „Owner-Zugriff“ sichtbar
- [ ] Nicht-Owner ohne Manage Guild → weiterhin kein Zugriff
## Post-Phase Owner Presence WebUI Polish (Status: implementiert) ## Post-Phase Owner Presence WebUI Polish (Status: implementiert)
### Abgeschlossen (Code) ### Abgeschlossen (Code)