deploy #2
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||
import { getManageableGuilds } from '@/lib/guilds';
|
||||
import { getManageableGuilds, resolveDashboardGuild } from '@/lib/guilds';
|
||||
import { isOwnerUser } from '@/lib/owner-auth';
|
||||
|
||||
interface GuildLayoutProps {
|
||||
@@ -13,23 +13,26 @@ interface GuildLayoutProps {
|
||||
export default async function GuildLayout({ children, params }: GuildLayoutProps) {
|
||||
const { guildId } = await params;
|
||||
const session = await requireGuildAccessOrRedirect(guildId);
|
||||
const guilds = await getManageableGuilds(session);
|
||||
const currentGuild = guilds.find((guild) => guild.id === guildId);
|
||||
const [resolved, guilds, showOwnerLink] = await Promise.all([
|
||||
resolveDashboardGuild(session, guildId),
|
||||
getManageableGuilds(session),
|
||||
isOwnerUser(session.user.id)
|
||||
]);
|
||||
|
||||
if (!currentGuild) {
|
||||
if (!resolved) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
|
||||
const showOwnerLink = await isOwnerUser(session.user.id);
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
guildId={guildId}
|
||||
currentGuild={currentGuild}
|
||||
currentGuild={resolved.guild}
|
||||
otherGuilds={otherGuilds}
|
||||
user={session.user}
|
||||
showOwnerLink={showOwnerLink}
|
||||
ownerBypass={resolved.ownerBypass}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { WelcomeForm } from '@/components/modules/welcome-form';
|
||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||
import { getManageableGuild } from '@/lib/guilds';
|
||||
import { resolveDashboardGuild } from '@/lib/guilds';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
|
||||
import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars';
|
||||
@@ -13,13 +13,13 @@ interface WelcomePageProps {
|
||||
export default async function WelcomePage({ params }: WelcomePageProps) {
|
||||
const { guildId } = await params;
|
||||
const session = await requireGuildAccessOrRedirect(guildId);
|
||||
const [locale, config, guild] = await Promise.all([
|
||||
const [locale, config, resolved] = await Promise.all([
|
||||
getLocale(),
|
||||
getWelcomeDashboard(guildId),
|
||||
getManageableGuild(session, guildId)
|
||||
resolveDashboardGuild(session, guildId)
|
||||
]);
|
||||
|
||||
if (!guild) {
|
||||
if (!resolved) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default async function WelcomePage({ params }: WelcomePageProps) {
|
||||
<WelcomeForm
|
||||
guildId={guildId}
|
||||
initialValue={config}
|
||||
previewVars={buildWelcomePreviewVars(session.user, guild)}
|
||||
previewVars={buildWelcomePreviewVars(session.user, resolved.guild)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ interface DashboardShellProps {
|
||||
otherGuilds: DashboardGuild[];
|
||||
user: SessionUser;
|
||||
showOwnerLink?: boolean;
|
||||
/** True when an Owner-panel user opened a guild they do not manage on Discord. */
|
||||
ownerBypass?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export function DashboardShell({
|
||||
otherGuilds,
|
||||
user,
|
||||
showOwnerLink = false,
|
||||
ownerBypass = false,
|
||||
children
|
||||
}: DashboardShellProps) {
|
||||
const t = useTranslations();
|
||||
@@ -59,7 +62,21 @@ export function DashboardShell({
|
||||
</div>
|
||||
</header>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState, useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -165,6 +166,9 @@ export function GuildsManager({
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
|
||||
import { ZodError } from 'zod';
|
||||
import { hasManageGuildPermission } from '@nexumi/shared';
|
||||
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||
import { hasOwnerGuildBypass } from './owner-auth';
|
||||
import { prisma } from './prisma';
|
||||
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
|
||||
* 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> {
|
||||
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 guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -68,15 +75,20 @@ export async function requireGuildAccess(guildId: string): Promise<SessionPayloa
|
||||
*/
|
||||
export async function requireGuildAccessOrRedirect(guildId: string): Promise<SessionPayload> {
|
||||
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 guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
|
||||
if (!dbGuild) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { hasManageGuildPermission, type DashboardGuild } from '@nexumi/shared';
|
||||
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||
import { hasOwnerGuildBypass } from './owner-auth';
|
||||
import { getOwnerGuildDiscordMeta } from './owner-guilds';
|
||||
import { prisma } from './prisma';
|
||||
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
|
||||
* 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
|
||||
* does not manage it.
|
||||
* does not manage it via Discord OAuth.
|
||||
*/
|
||||
export async function getManageableGuild(
|
||||
session: SessionPayload,
|
||||
@@ -49,3 +54,39 @@ export async function getManageableGuild(
|
||||
const guilds = await getManageableGuilds(session);
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ export async function isOwnerUser(userId: string): Promise<boolean> {
|
||||
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> {
|
||||
const session = await requireAuth();
|
||||
const role = await resolveOwnerRole(session.user.id);
|
||||
|
||||
@@ -273,6 +273,11 @@
|
||||
"botMissing": "Nicht installiert",
|
||||
"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": {
|
||||
"title": "Übersicht",
|
||||
"moduleStatusTitle": "Modul-Status",
|
||||
@@ -997,6 +1002,7 @@
|
||||
"createInvite": "Invite erstellen",
|
||||
"inviteCopied": "Invite kopiert: {url}",
|
||||
"inviteCreated": "Invite erstellt: {url}",
|
||||
"openDashboard": "Dashboard öffnen",
|
||||
"blacklisted": "Blacklist",
|
||||
"blacklist": "Blacklisten",
|
||||
"unblacklist": "Blacklist entfernen",
|
||||
|
||||
@@ -273,6 +273,11 @@
|
||||
"botMissing": "Not installed",
|
||||
"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": {
|
||||
"title": "Overview",
|
||||
"moduleStatusTitle": "Module status",
|
||||
@@ -997,6 +1002,7 @@
|
||||
"createInvite": "Create invite",
|
||||
"inviteCopied": "Invite copied: {url}",
|
||||
"inviteCreated": "Invite created: {url}",
|
||||
"openDashboard": "Open dashboard",
|
||||
"blacklisted": "Blacklisted",
|
||||
"blacklist": "Blacklist",
|
||||
"unblacklist": "Remove blacklist",
|
||||
|
||||
@@ -320,6 +320,20 @@
|
||||
- [ ] Suche nach Servername filtert
|
||||
- [ ] 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)
|
||||
|
||||
### Abgeschlossen (Code)
|
||||
|
||||
Reference in New Issue
Block a user