feat: Añadir soporte para autenticación de Discord y gestión de sesiones, incluyendo rutas de OAuth y almacenamiento de estado
This commit is contained in:
@@ -41,6 +41,7 @@ GENAI_IMAGE_MODEL=
|
||||
# Slash command registration context
|
||||
# Your Discord Application ID
|
||||
CLIENT=
|
||||
CLIENT_SECRET=
|
||||
# Test guild ID (for per-guild command registration)
|
||||
guildTest=
|
||||
|
||||
|
||||
99
scripts/smokeQuestsAchievements.ts
Normal file
99
scripts/smokeQuestsAchievements.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/core/database/prisma";
|
||||
import { seedAchievements } from "../src/game/achievements/seed";
|
||||
import {
|
||||
generateDailyQuests,
|
||||
updateQuestProgress,
|
||||
claimQuestReward,
|
||||
getPlayerQuests,
|
||||
} from "../src/game/quests/service";
|
||||
import {
|
||||
checkAchievements,
|
||||
getPlayerAchievements,
|
||||
} from "../src/game/achievements/service";
|
||||
|
||||
async function ensureGuildAndUser(guildId: string, userId: string) {
|
||||
await prisma.guild.upsert({
|
||||
where: { id: guildId },
|
||||
update: { name: "test" },
|
||||
create: { id: guildId, name: "test", prefix: "!" },
|
||||
});
|
||||
await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
update: {},
|
||||
create: { id: userId },
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const guildId = "test-guild-quests";
|
||||
const userId = "test-user-1";
|
||||
await ensureGuildAndUser(guildId, userId);
|
||||
|
||||
console.log("Seeding achievements global...");
|
||||
await seedAchievements(null);
|
||||
|
||||
console.log("Seeding achievements for guild...");
|
||||
await seedAchievements(guildId);
|
||||
|
||||
console.log("Generating daily quests for guild...");
|
||||
await generateDailyQuests(guildId);
|
||||
|
||||
console.log("Player quests before progress:");
|
||||
console.log(await getPlayerQuests(userId, guildId));
|
||||
|
||||
// Buscar una quest de tipo fight_count
|
||||
const quests = await prisma.quest.findMany({
|
||||
where: { guildId, type: "daily" },
|
||||
});
|
||||
const q = quests.find((q) => (q.requirements as any).type === "fight_count");
|
||||
if (!q) {
|
||||
console.log("No daily fight quest found, picking first.");
|
||||
}
|
||||
const questToUse = q || quests[0];
|
||||
console.log("Using quest:", questToUse?.key);
|
||||
|
||||
// Simular progreso para completarla
|
||||
const req = (questToUse.requirements as any) || { count: 1 };
|
||||
const needed = req.count || 1;
|
||||
console.log("Incrementing progress by", needed);
|
||||
const updates = await updateQuestProgress(
|
||||
userId,
|
||||
guildId,
|
||||
req.type || "fight_count",
|
||||
needed
|
||||
);
|
||||
console.log(
|
||||
"Quests completed by updateQuestProgress:",
|
||||
updates.map((u) => u.key)
|
||||
);
|
||||
|
||||
// Intentar reclamar
|
||||
const progressRow = await prisma.questProgress.findFirst({
|
||||
where: { userId, guildId, questId: questToUse.id, completed: true },
|
||||
});
|
||||
if (progressRow) {
|
||||
const claim = await claimQuestReward(userId, guildId, questToUse.id);
|
||||
console.log("Claim result:", claim);
|
||||
} else {
|
||||
console.log("No completed quest progress to claim");
|
||||
}
|
||||
|
||||
// Check achievements trigger
|
||||
console.log("Checking achievements for fight_count");
|
||||
const unlocked = await checkAchievements(userId, guildId, "fight_count");
|
||||
console.log(
|
||||
"Achievements unlocked:",
|
||||
unlocked.map((a) => a.key)
|
||||
);
|
||||
|
||||
console.log("Player achievements summary:");
|
||||
console.log(await getPlayerAchievements(userId, guildId));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
10
src/server/public/assets/css/dashboard-glass.css
Normal file
10
src/server/public/assets/css/dashboard-glass.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/* Minimal glassmorphism + tailwind helpers */
|
||||
.glass-card {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
box-shadow: 0 6px 20px rgba(8,10,15,0.6);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass-card { backdrop-filter: none; background: rgba(20,20,20,0.8); }
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "node:zlib";
|
||||
import path from "node:path";
|
||||
import ejs from "ejs";
|
||||
import { prisma } from "../core/database/prisma";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const publicDir = path.join(__dirname, "public");
|
||||
const viewsDir = path.join(__dirname, "views");
|
||||
@@ -44,6 +46,169 @@ const MIME_TYPES: Record<string, string> = {
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
||||
// Simple in-memory stores (keep small, restart clears data)
|
||||
const SESSIONS = new Map<string, any>();
|
||||
const STATE_STORE = new Map<string, { ts: number }>();
|
||||
|
||||
// Configurable limits and TTLs (tune for production)
|
||||
const STATE_TTL_MS = Number(process.env.STATE_TTL_MS) || 5 * 60 * 1000; // 5 minutes
|
||||
const SESSION_TTL_MS =
|
||||
Number(process.env.SESSION_TTL_MS) || 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 2000; // cap entries to control RAM
|
||||
|
||||
function parseCookies(req: IncomingMessage) {
|
||||
const raw = (req.headers.cookie as string) || "";
|
||||
return raw
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.reduce<Record<string, string>>((acc, cur) => {
|
||||
const idx = cur.indexOf("=");
|
||||
if (idx === -1) return acc;
|
||||
const k = cur.slice(0, idx).trim();
|
||||
const v = cur.slice(idx + 1).trim();
|
||||
acc[k] = decodeURIComponent(v);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function setSessionCookie(res: ServerResponse, sid: string) {
|
||||
const cookie = `amayo_sid=${encodeURIComponent(
|
||||
sid
|
||||
)}; HttpOnly; Path=/; Max-Age=${60 * 60 * 24 * 7}`; // 7 days
|
||||
res.setHeader("Set-Cookie", cookie);
|
||||
}
|
||||
|
||||
function clearSessionCookie(res: ServerResponse) {
|
||||
res.setHeader("Set-Cookie", `amayo_sid=; HttpOnly; Path=/; Max-Age=0`);
|
||||
}
|
||||
|
||||
function storeState(state: string) {
|
||||
STATE_STORE.set(state, { ts: Date.now() });
|
||||
}
|
||||
|
||||
function hasState(state: string) {
|
||||
const v = STATE_STORE.get(state);
|
||||
if (!v) return false;
|
||||
if (Date.now() - v.ts > STATE_TTL_MS) {
|
||||
STATE_STORE.delete(state);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function createSession(data: any) {
|
||||
// Evict oldest if over cap
|
||||
if (SESSIONS.size >= MAX_SESSIONS) {
|
||||
// delete ~10% oldest entries
|
||||
const toRemove = Math.max(1, Math.floor(MAX_SESSIONS * 0.1));
|
||||
const it = SESSIONS.keys();
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
const k = it.next().value;
|
||||
if (!k) break;
|
||||
SESSIONS.delete(k);
|
||||
}
|
||||
}
|
||||
const sid = randomUUID();
|
||||
SESSIONS.set(sid, { ...data, created: Date.now(), lastSeen: Date.now() });
|
||||
return sid;
|
||||
}
|
||||
|
||||
function touchSession(sid: string) {
|
||||
const s = SESSIONS.get(sid);
|
||||
if (!s) return;
|
||||
s.lastSeen = Date.now();
|
||||
}
|
||||
|
||||
async function refreshAccessTokenIfNeeded(session: any) {
|
||||
// session expected to have refresh_token and expires_at (ms)
|
||||
if (!session) return session;
|
||||
const now = Date.now();
|
||||
if (!session.refresh_token) return session;
|
||||
// If token expires in next 60s, refresh
|
||||
if (!session.expires_at || session.expires_at - now <= 60 * 1000) {
|
||||
try {
|
||||
const clientId = process.env.CLIENT || "";
|
||||
const clientSecret = process.env.CLIENT_SECRET || "";
|
||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: session.refresh_token,
|
||||
} as any).toString(),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error("Refresh failed");
|
||||
const tokenJson = await tokenRes.json();
|
||||
session.access_token = tokenJson.access_token;
|
||||
session.refresh_token = tokenJson.refresh_token || session.refresh_token;
|
||||
session.expires_at =
|
||||
Date.now() + Number(tokenJson.expires_in || 3600) * 1000;
|
||||
} catch (err) {
|
||||
console.warn("Token refresh error", err);
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
// Periodic cleanup for memory-sensitive stores
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
// Cleanup state store
|
||||
for (const [k, v] of STATE_STORE.entries()) {
|
||||
if (now - v.ts > STATE_TTL_MS) STATE_STORE.delete(k);
|
||||
}
|
||||
// Cleanup sessions older than TTL
|
||||
for (const [k, s] of SESSIONS.entries()) {
|
||||
if (now - (s.lastSeen || s.created || 0) > SESSION_TTL_MS)
|
||||
SESSIONS.delete(k);
|
||||
}
|
||||
}, Math.max(30_000, Math.min(5 * 60_000, STATE_TTL_MS)));
|
||||
|
||||
// --- Input sanitization helpers ---
|
||||
function stripControlChars(s: string) {
|
||||
return s.replace(/\x00|[\x00-\x1F\x7F]+/g, "");
|
||||
}
|
||||
|
||||
function sanitizeString(v: unknown, opts?: { max?: number }) {
|
||||
if (v == null) return "";
|
||||
let s = String(v);
|
||||
s = stripControlChars(s);
|
||||
// Remove script tags and angle-bracket injections
|
||||
s = s.replace(/<\/?\s*script[^>]*>/gi, "");
|
||||
s = s.replace(/[<>]/g, "");
|
||||
const max = opts?.max ?? 200;
|
||||
if (s.length > max) s = s.slice(0, max);
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
function validateDiscordId(id: unknown) {
|
||||
if (!id) return false;
|
||||
const s = String(id);
|
||||
return /^\d{17,20}$/.test(s);
|
||||
}
|
||||
|
||||
async function safeUpsertGuild(g: any) {
|
||||
if (!g) return;
|
||||
if (!validateDiscordId(g.id)) {
|
||||
console.warn("Skipping upsert: invalid guild id", g && g.id);
|
||||
return;
|
||||
}
|
||||
const gid = String(g.id);
|
||||
const name = sanitizeString(g.name ?? gid, { max: 100 });
|
||||
try {
|
||||
await prisma.guild.upsert({
|
||||
where: { id: gid },
|
||||
update: { name },
|
||||
create: { id: gid, name, prefix: "!" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("safeUpsertGuild failed for", gid, err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Basic hardening: blocklist patterns and lightweight rate limiting for suspicious paths ---
|
||||
const BLOCKED_PATTERNS: RegExp[] = [
|
||||
/\b(npci|upi|bhim|aadhaar|aadhar|cts|fastag|bbps|rgcs|nuup|apbs|hdfc|ergo|securities|banking|insurance)\b/i,
|
||||
@@ -247,10 +412,9 @@ const sendResponse = async (
|
||||
): Promise<void> => {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const mimeType = MIME_TYPES[extension] || "application/octet-stream";
|
||||
const cacheControl = extension.match(/\.(?:html)$/)
|
||||
? "no-cache"
|
||||
: "public, max-age=86400, immutable";
|
||||
|
||||
const cacheControl = extension.match(/\.(?:html)$/)
|
||||
? "no-cache"
|
||||
: "public, max-age=86400, immutable";
|
||||
|
||||
const stat = await fs.stat(filePath).catch(() => undefined);
|
||||
const data = await fs.readFile(filePath);
|
||||
@@ -453,6 +617,203 @@ export const server = createServer(
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Auth routes (Discord OAuth minimal flow) ---
|
||||
if (url.pathname === "/auth/discord") {
|
||||
// Redirect to Discord OAuth2 authorize
|
||||
const clientId = process.env.DISCORD_CLIENT_ID || "";
|
||||
const redirectUri =
|
||||
process.env.DISCORD_REDIRECT_URI ||
|
||||
`http://${req.headers.host}/auth/callback`;
|
||||
const state = randomUUID();
|
||||
// Store state in a temp session map
|
||||
SESSIONS.set(state, { ts: Date.now() });
|
||||
const scopes = encodeURIComponent("identify guilds");
|
||||
const urlAuth = `https://discord.com/api/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(
|
||||
clientId
|
||||
)}&scope=${scopes}&state=${state}&redirect_uri=${encodeURIComponent(
|
||||
redirectUri
|
||||
)}`;
|
||||
res.writeHead(
|
||||
302,
|
||||
applySecurityHeadersForRequest(req, { Location: urlAuth })
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const qs = Object.fromEntries(url.searchParams.entries());
|
||||
const { code, state } = qs as any;
|
||||
// Validate state
|
||||
if (!state || !SESSIONS.has(state)) {
|
||||
res.writeHead(400, applySecurityHeadersForRequest(req));
|
||||
return res.end("Invalid OAuth state");
|
||||
}
|
||||
const clientId = process.env.DISCORD_CLIENT_ID || "";
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET || "";
|
||||
const redirectUri =
|
||||
process.env.DISCORD_REDIRECT_URI ||
|
||||
`http://${req.headers.host}/auth/callback`;
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, applySecurityHeadersForRequest(req));
|
||||
return res.end("Missing code");
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for token
|
||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
} as any).toString(),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error("Token exchange failed");
|
||||
const tokenJson = await tokenRes.json();
|
||||
const accessToken = tokenJson.access_token;
|
||||
|
||||
// Fetch user
|
||||
const userRes = await fetch("https://discord.com/api/users/@me", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!userRes.ok) throw new Error("Failed fetching user");
|
||||
const userJson = await userRes.json();
|
||||
|
||||
// Fetch guilds
|
||||
const guildsRes = await fetch(
|
||||
"https://discord.com/api/users/@me/guilds",
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
);
|
||||
const guildsJson = guildsRes.ok ? await guildsRes.json() : [];
|
||||
|
||||
// Filter guilds where user is owner or has ADMINISTRATOR bit
|
||||
const ADMIN_BIT = 0x8;
|
||||
const adminGuilds = (
|
||||
Array.isArray(guildsJson) ? guildsJson : []
|
||||
).filter((g: any) => {
|
||||
try {
|
||||
const perms = Number(g.permissions || 0);
|
||||
return g.owner || (perms & ADMIN_BIT) === ADMIN_BIT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Upsert guilds to DB safely
|
||||
for (const g of adminGuilds) {
|
||||
await safeUpsertGuild({ id: g.id, name: g.name });
|
||||
}
|
||||
|
||||
// Sanitize user and guilds before storing in session
|
||||
const uid = validateDiscordId(userJson?.id)
|
||||
? String(userJson.id)
|
||||
: null;
|
||||
const uname = sanitizeString(userJson?.username ?? "DiscordUser", {
|
||||
max: 100,
|
||||
});
|
||||
const uavatar = sanitizeString(userJson?.avatar ?? "", { max: 200 });
|
||||
const safeGuilds = (adminGuilds || []).map((g: any) => ({
|
||||
id: String(g.id),
|
||||
name: sanitizeString(g.name ?? g.id, { max: 100 }),
|
||||
}));
|
||||
|
||||
const sid = randomUUID();
|
||||
const session = {
|
||||
user: {
|
||||
id: uid,
|
||||
username: uname,
|
||||
avatar: uavatar,
|
||||
},
|
||||
guilds: safeGuilds,
|
||||
};
|
||||
SESSIONS.set(sid, session);
|
||||
setSessionCookie(res, sid);
|
||||
res.writeHead(
|
||||
302,
|
||||
applySecurityHeadersForRequest(req, { Location: "/dashboard" })
|
||||
);
|
||||
return res.end();
|
||||
} catch (err: any) {
|
||||
console.error("OAuth callback error:", err);
|
||||
res.writeHead(500, applySecurityHeadersForRequest(req));
|
||||
return res.end("OAuth error");
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/auth/logout") {
|
||||
const cookies = parseCookies(req);
|
||||
const sid = cookies["amayo_sid"];
|
||||
if (sid) SESSIONS.delete(sid);
|
||||
clearSessionCookie(res);
|
||||
res.writeHead(
|
||||
302,
|
||||
applySecurityHeadersForRequest(req, { Location: "/" })
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// Dashboard routes
|
||||
if (
|
||||
url.pathname === "/dashboard" ||
|
||||
url.pathname.startsWith("/dashboard")
|
||||
) {
|
||||
const cookies = parseCookies(req);
|
||||
const sid = cookies["amayo_sid"];
|
||||
const session = sid ? SESSIONS.get(sid) : null;
|
||||
const user = session?.user ?? null;
|
||||
|
||||
// If not authenticated, redirect to login page
|
||||
if (!user) {
|
||||
await renderTemplate(req, res, "login", {
|
||||
appName: pkg.name ?? "Amayo Bot",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple guild list: for demo, fetch guilds where guild.staff contains user.id (not implemented fully)
|
||||
const guilds = await (async () => {
|
||||
try {
|
||||
const rows = await prisma.guild.findMany({ take: 10 });
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
// /dashboard -> main dashboard
|
||||
if (url.pathname === "/dashboard" || url.pathname === "/dashboard/") {
|
||||
await renderTemplate(req, res, "dashboard", {
|
||||
appName: pkg.name ?? "Amayo Bot",
|
||||
user,
|
||||
guilds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Dashboard subpaths (e.g. /dashboard/:guildId/overview)
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
// parts[0] === 'dashboard'
|
||||
if (parts.length >= 2) {
|
||||
const guildId = parts[1];
|
||||
const page = parts[2] || "overview";
|
||||
// For now render same dashboard with selected guild context stub
|
||||
await renderTemplate(req, res, "dashboard", {
|
||||
appName: pkg.name ?? "Amayo Bot",
|
||||
user,
|
||||
guilds,
|
||||
selectedGuild: guildId,
|
||||
page,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = resolvePath(url.pathname);
|
||||
|
||||
if (!filePath.startsWith(publicDir)) {
|
||||
|
||||
43
src/server/views/pages/dashboard.ejs
Normal file
43
src/server/views/pages/dashboard.ejs
Normal file
@@ -0,0 +1,43 @@
|
||||
<div class="max-w-6xl mx-auto p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Card principal -->
|
||||
<div class="col-span-2">
|
||||
<div class="backdrop-blur-md bg-white/10 border border-white/10 rounded-xl p-6 shadow-lg glass-card">
|
||||
<h1 class="text-3xl font-bold mb-2"><%= appName %></h1>
|
||||
<p class="text-sm text-slate-200/80 mb-4">Panel de administración</p>
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs text-slate-300 mb-2">Selecciona servidor</label>
|
||||
<select id="guildSelector" class="w-full rounded-md p-3 bg-white/6 text-white focus:outline-none">
|
||||
<% if (guilds && guilds.length) { %>
|
||||
<% guilds.forEach(g => { %>
|
||||
<option value="<%= g.id %>"><%= g.name %> (<%= g.id %>)</option>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<option disabled>No tienes servidores gestionados</option>
|
||||
<% } %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar navegación móvil estilo app -->
|
||||
<aside class="col-span-1 md:col-span-1">
|
||||
<nav class="bg-white/6 backdrop-blur rounded-xl p-4 glass-card">
|
||||
<ul class="flex md:flex-col gap-2">
|
||||
<li><a href="/dashboard/overview" class="block p-2 rounded-md hover:bg-white/5">Overview</a></li>
|
||||
<li><a href="/dashboard/members" class="block p-2 rounded-md hover:bg-white/5">Miembros</a></li>
|
||||
<li><a href="/dashboard/settings" class="block p-2 rounded-md hover:bg-white/5">Ajustes</a></li>
|
||||
<li><a href="/dashboard/areas" class="block p-2 rounded-md hover:bg-white/5">Game Areas</a></li>
|
||||
<li><a href="/dashboard/mobs" class="block p-2 rounded-md hover:bg-white/5">Mobs</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('guildSelector')?.addEventListener('change', (e) => {
|
||||
const v = e.target.value;
|
||||
if (v) window.location.href = `/dashboard/${v}/overview`;
|
||||
});
|
||||
</script>
|
||||
7
src/server/views/pages/login.ejs
Normal file
7
src/server/views/pages/login.ejs
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md p-6 glass-card backdrop-blur-md rounded-xl">
|
||||
<h2 class="text-2xl font-bold mb-4">Inicia sesión con Discord</h2>
|
||||
<p class="text-sm mb-6">Para administrar servidores y usar el dashboard debes autenticarte con Discord.</p>
|
||||
<a class="inline-block px-4 py-3 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white" href="/auth/discord">Continuar con Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
22
src/server/views/partials/dashboard_nav.ejs
Normal file
22
src/server/views/partials/dashboard_nav.ejs
Normal file
@@ -0,0 +1,22 @@
|
||||
<header class="w-full bg-transparent p-3 md:p-4 fixed top-0 left-0 right-0 z-20">
|
||||
<div class="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-white font-bold">← Volver</a>
|
||||
<span class="text-white/80">|</span>
|
||||
<h3 class="text-white font-semibold"><%= appName %></h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if (user) { %>
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="<%= user.avatar || '/assets/images/snap1.svg' %>" class="w-8 h-8 rounded-full" alt="avatar">
|
||||
<span class="text-white"><%= user.username %></span>
|
||||
</div>
|
||||
<a href="/auth/logout" class="text-sm text-white/70 px-3 py-2 rounded-md hover:bg-white/5">Salir</a>
|
||||
<% } else { %>
|
||||
<a href="/auth/discord" class="text-sm text-white/70 px-3 py-2 rounded-md hover:bg-white/5">Entrar</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="height:56px"></div>
|
||||
Reference in New Issue
Block a user