From 2dc1a141f30c52a0d87881f17a60314c901cf92a Mon Sep 17 00:00:00 2001 From: Haxurus <72351758+haxurus@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:26:25 +0200 Subject: [PATCH 1/2] feat: getting-started guide, localized 404 page, security.txt and welcome message - /en|it/getting-started: setup guide (HowTo JSON-LD), linked from header, footer, sitemap and the 404 page. - Localized 404 page for unknown paths under /en, /it and the root. - /.well-known/security.txt (RFC 9116) pointing to private reporting. - Bot: one-time welcome when added to a server, in the system (or first writable) channel and by DM to the owner, with dashboard, guide, privacy and support links. The capability policy now allows opening a one-to-one DM (POST /users/@me/channels with recipient_id only); group DMs stay blocked. - Privacy page, README and distribution rules updated. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- apps/bot/src/capability-policy-check.ts | 12 ++ apps/bot/src/capability-policy.ts | 17 +- apps/bot/src/config.ts | 5 +- apps/bot/src/handlers.ts | 2 + apps/bot/src/welcome.ts | 99 +++++++++ apps/web/app/(en)/en/[...missing]/page.tsx | 4 + apps/web/app/(en)/en/getting-started/page.tsx | 12 ++ apps/web/app/(en)/en/not-found.tsx | 3 + apps/web/app/(it)/it/[...missing]/page.tsx | 4 + apps/web/app/(it)/it/getting-started/page.tsx | 12 ++ apps/web/app/(it)/it/not-found.tsx | 3 + apps/web/app/(root)/[...missing]/page.tsx | 4 + apps/web/app/(root)/not-found.tsx | 3 + .../app/_components/GettingStartedPage.tsx | 190 ++++++++++++++++++ apps/web/app/_components/LegalPage.tsx | 4 +- apps/web/app/_components/NotFoundPage.tsx | 43 ++++ apps/web/app/_components/PublicHome.tsx | 6 +- apps/web/app/_components/SiteChrome.tsx | 2 +- apps/web/app/globals.css | 7 + apps/web/app/sitemap.ts | 1 + apps/web/public/.well-known/security.txt | 7 + deploy/docker-compose.prod.yml | 2 + docker-compose.yml | 2 + docs/DISTRIBUTION.md | 1 + 25 files changed, 440 insertions(+), 7 deletions(-) create mode 100644 apps/bot/src/welcome.ts create mode 100644 apps/web/app/(en)/en/[...missing]/page.tsx create mode 100644 apps/web/app/(en)/en/getting-started/page.tsx create mode 100644 apps/web/app/(en)/en/not-found.tsx create mode 100644 apps/web/app/(it)/it/[...missing]/page.tsx create mode 100644 apps/web/app/(it)/it/getting-started/page.tsx create mode 100644 apps/web/app/(it)/it/not-found.tsx create mode 100644 apps/web/app/(root)/[...missing]/page.tsx create mode 100644 apps/web/app/(root)/not-found.tsx create mode 100644 apps/web/app/_components/GettingStartedPage.tsx create mode 100644 apps/web/app/_components/NotFoundPage.tsx create mode 100644 apps/web/public/.well-known/security.txt diff --git a/README.md b/README.md index 6e29bac..b592f54 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ More: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). A logging bot sees a lot, so Sentinel is designed to limit what a compromised component could do. -- **Read-only Discord capability policy.** The bot process allows only REST reads, posting log messages, leaving a server and changing its own nickname and banner in a server (Brand plan; any other member field is rejected). Bans, kicks, timeouts, role/channel/message changes, webhooks and AutoMod changes are blocked in-process, regardless of the permissions given to the bot role. A build-time check (`policy:check`) rejects code that tries to bypass it. +- **Read-only Discord capability policy.** The bot process allows only REST reads, posting log messages, leaving a server, changing its own nickname and banner in a server (Brand plan; any other member field is rejected) and opening a direct message with a single user, used once for the welcome to a server's owner (group DMs are rejected). Bans, kicks, timeouts, role/channel/message changes, webhooks and AutoMod changes are blocked in-process, regardless of the permissions given to the bot role. A build-time check (`policy:check`) rejects code that tries to bypass it. - **Separated secrets.** The Discord token exists only in the bot container; the web container has no application secrets; secrets live in root-only files on the host. - **Encryption at rest.** Message content, attachments, embeds, event details and summaries, and message authors are encrypted with AES-256-GCM; pre-deploy database backups are encrypted with age to a key kept off the server. - **Data minimisation.** No online status, activities or typing are recorded. History expires with the plan's retention, and when the bot is removed from a server all of that server's data and settings are deleted 30 days later unless it is added back. diff --git a/apps/bot/src/capability-policy-check.ts b/apps/bot/src/capability-policy-check.ts index a325bf4..d6ebb64 100644 --- a/apps/bot/src/capability-policy-check.ts +++ b/apps/bot/src/capability-policy-check.ts @@ -56,6 +56,18 @@ for (const [method, route] of allowed) { evaluateDiscordCapability(method, route); } evaluateDiscordCapability('PATCH', '/guilds/123456789012345678/members/@me', { nick: 'Sentinel', banner: null }); +evaluateDiscordCapability('POST', '/users/@me/channels', { recipient_id: '234567890123456789' }); + +// Only a one-to-one DM may be opened: no group DMs, no extra fields. +for (const body of [undefined, {}, { recipient_id: 'x' }, { recipient_id: '234567890123456789', nicks: {} }, { access_tokens: [], nicks: {} }, [{ recipient_id: '234567890123456789' }]]) { + let rejected = false; + try { + evaluateDiscordCapability('POST', '/users/@me/channels', body); + } catch { + rejected = true; + } + if (!rejected) throw new Error(`Capability policy allowed a DM channel body: ${JSON.stringify(body)}`); +} // The own-profile route must never carry moderation fields. for (const body of [{ roles: [] }, { mute: true }, { deaf: true }, { channel_id: null }, { communication_disabled_until: '2030-01-01' }, { nick: 'x', roles: [] }, 'nick=x', null]) { diff --git a/apps/bot/src/capability-policy.ts b/apps/bot/src/capability-policy.ts index 8acd72a..008e9b5 100644 --- a/apps/bot/src/capability-policy.ts +++ b/apps/bot/src/capability-policy.ts @@ -3,7 +3,7 @@ import type { Client } from 'discord.js'; export type DiscordCapabilityDecision = { method: string; route: string; - capability: 'read' | 'send-log-message' | 'leave-guild' | 'edit-own-profile'; + capability: 'read' | 'send-log-message' | 'leave-guild' | 'edit-own-profile' | 'open-dm'; }; export class DiscordCapabilityViolation extends Error { @@ -27,6 +27,10 @@ const LEAVE_GUILD_ROUTE = /^\/users\/@me\/guilds\/\d{17,20}$/; // channel moves and timeouts live on /members/:userId and stay blocked. const OWN_MEMBER_ROUTE = /^\/guilds\/\d{17,20}\/members\/@me$/; const OWN_PROFILE_FIELDS = new Set(['nick', 'banner']); +// Opening a direct-message channel with one user, used only for the one-time +// welcome to a server owner. Group DMs (access_tokens, nicks) stay blocked. +const OPEN_DM_ROUTE = /^\/users\/@me\/channels$/; +const SNOWFLAKE = /^\d{17,20}$/; function normalizeMethod(value: unknown) { return String(value ?? '').trim().toUpperCase(); @@ -55,6 +59,13 @@ function ownProfileBodyAllowed(body: unknown) { return Object.keys(body).every((key) => OWN_PROFILE_FIELDS.has(key)); } +function openDmBodyAllowed(body: unknown) { + if (!body || typeof body !== 'object' || Array.isArray(body)) return false; + const keys = Object.keys(body); + const recipient = (body as Record).recipient_id; + return keys.length === 1 && keys[0] === 'recipient_id' && typeof recipient === 'string' && SNOWFLAKE.test(recipient); +} + export function evaluateDiscordCapability(methodValue: unknown, routeValue: unknown, body?: unknown): DiscordCapabilityDecision { const method = normalizeMethod(methodValue); const route = normalizeDiscordRoute(routeValue); @@ -73,6 +84,10 @@ export function evaluateDiscordCapability(methodValue: unknown, routeValue: unkn return { method, route, capability: 'edit-own-profile' }; } + if (method === 'POST' && OPEN_DM_ROUTE.test(route) && openDmBodyAllowed(body)) { + return { method, route, capability: 'open-dm' }; + } + throw new DiscordCapabilityViolation(method || '', route); } diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 19b4fb5..ac00fd9 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -19,5 +19,8 @@ export const config = { internalApiKey, internalApiPort: Number(process.env.BOT_INTERNAL_PORT ?? 3002), redisUrl, - logLevel: process.env.LOG_LEVEL ?? 'info' + logLevel: process.env.LOG_LEVEL ?? 'info', + // Public links in the welcome message. Self-hosted instances set their own. + webUrl: (process.env.WEB_URL?.trim() || 'https://sentinel.haxurus.com').replace(/\/$/, ''), + supportUrl: process.env.SUPPORT_URL?.trim() || 'https://discord.gg/DDBGWP5fMX' }; diff --git a/apps/bot/src/handlers.ts b/apps/bot/src/handlers.ts index 6ead80c..a34e397 100644 --- a/apps/bot/src/handlers.ts +++ b/apps/bot/src/handlers.ts @@ -11,6 +11,7 @@ import { } from 'discord.js'; import { prisma } from '@sentinel/db'; import { ensureGuild, getGuildSettings, isGuildInstallBlocked, isUserInstallBlocked, markGuildRemoved, snapshotMessage } from './store.js'; +import { sendWelcome } from './welcome.js'; import { recordEvent, shouldCapture } from './recorder.js'; import { findRecentAuditEntry, jsonSafe } from './utils.js'; import { decryptText, redactText, unprotectJson } from './security.js'; @@ -130,6 +131,7 @@ export function registerHandlers(client: Client, status: StatusNotifier) { { name: 'Aggiunto da', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : 'sconosciuto' } ] }); + await sendWelcome(guild); }); on(Events.GuildMemberAdd, async (member) => { diff --git a/apps/bot/src/welcome.ts b/apps/bot/src/welcome.ts new file mode 100644 index 0000000..184db61 --- /dev/null +++ b/apps/bot/src/welcome.ts @@ -0,0 +1,99 @@ +import { ChannelType, EmbedBuilder, PermissionFlagsBits, type Guild, type TextChannel } from 'discord.js'; +import { config } from './config.js'; +import { logger } from './logger.js'; + +/** + * One-time onboarding when Sentinel is added to a server: a message in the + * server and a direct message to its owner, with the setup steps, the privacy + * policy and the support contact. Nothing else is ever sent by DM (Discord + * Developer Policy: messages must relate to the app's functionality). + */ + +const ACCENT = 0xa78bfa; +const NEEDED = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks]; + +type Locale = 'en' | 'it'; + +const copy = { + en: { + title: 'Thanks for adding Sentinel', + intro: 'Sentinel records what happens on this server and posts it to the log channels you choose. It is read-only: it never bans, kicks or deletes anything.', + stepsTitle: 'Set it up in a few minutes', + steps: (web: string, guildId: string) => [ + `1. Open the [dashboard](${web}/en/dashboard/${guildId}) and sign in with Discord.`, + '2. Choose a **default log channel**: Sentinel needs *View Channel*, *Send Messages* and *Embed Links* there.', + '3. Turn on the **loggers** you need. Everything is configured from the dashboard, there are no commands.' + ].join('\n'), + guide: 'Guide', + privacy: 'Privacy and terms', + support: 'Support', + links: (web: string, support: string) => `[Getting started](${web}/en/getting-started) · [Privacy policy and terms](${web}/en/legal) · [Support server](${support})`, + ownerNote: (guild: string) => `You are receiving this once because Sentinel was added to **${guild}**, which you own. Sentinel does not send any other direct messages.`, + footer: 'Sentinel · Discord logging bot' + }, + it: { + title: 'Grazie per aver aggiunto Sentinel', + intro: 'Sentinel registra ciò che accade in questo server e lo pubblica nei canali di log che scegli. È in sola lettura: non banna, non espelle e non elimina nulla.', + stepsTitle: 'Configuralo in pochi minuti', + steps: (web: string, guildId: string) => [ + `1. Apri la [dashboard](${web}/it/dashboard/${guildId}) e accedi con Discord.`, + '2. Scegli un **canale di log predefinito**: lì Sentinel deve poter *vedere il canale*, *inviare messaggi* e *incorporare link*.', + '3. Attiva i **logger** che ti servono. Si configura tutto dalla dashboard, non ci sono comandi.' + ].join('\n'), + guide: 'Guida', + privacy: 'Privacy e termini', + support: 'Supporto', + links: (web: string, support: string) => `[Primi passi](${web}/it/getting-started) · [Privacy e termini](${web}/it/legal) · [Server di supporto](${support})`, + ownerNote: (guild: string) => `Ricevi questo messaggio una sola volta perché Sentinel è stato aggiunto a **${guild}**, di cui sei proprietario. Sentinel non invia altri messaggi privati.`, + footer: 'Sentinel · bot di log per Discord' + } +} as const; + +export const welcomeLocale = (preferredLocale: string | null | undefined): Locale => + preferredLocale?.toLowerCase().startsWith('it') ? 'it' : 'en'; + +/** Escapes a server name so it cannot inject markdown or mentions. */ +const plain = (value: string) => value.replace(/[\\*_~`|>[\]()@#<]/g, (char) => `\\${char}`).slice(0, 100); + +export function welcomeEmbed(locale: Locale, guildId: string, guildName: string | null, forOwner: boolean) { + const c = copy[locale]; + const embed = new EmbedBuilder() + .setColor(ACCENT) + .setTitle(c.title) + .setDescription(c.intro) + .addFields( + { name: c.stepsTitle, value: c.steps(config.webUrl, guildId) }, + { name: `${c.guide} · ${c.privacy} · ${c.support}`, value: c.links(config.webUrl, config.supportUrl) } + ) + .setFooter({ text: c.footer }); + if (forOwner && guildName) embed.addFields({ name: '​', value: c.ownerNote(plain(guildName)) }); + return embed; +} + +/** The system channel if Sentinel can post there, otherwise the first text channel it can. */ +function welcomeChannel(guild: Guild): TextChannel | null { + const me = guild.members.me; + if (!me) return null; + const canPost = (channel: TextChannel) => channel.permissionsFor(me)?.has(NEEDED) ?? false; + if (guild.systemChannel && canPost(guild.systemChannel)) return guild.systemChannel; + const candidates = [...guild.channels.cache.values()] + .filter((channel): channel is TextChannel => channel.type === ChannelType.GuildText) + .sort((a, b) => a.position - b.position); + return candidates.find(canPost) ?? null; +} + +export async function sendWelcome(guild: Guild) { + const locale = welcomeLocale(guild.preferredLocale); + const channel = welcomeChannel(guild); + if (channel) { + await channel.send({ embeds: [welcomeEmbed(locale, guild.id, guild.name, false)], allowedMentions: { parse: [] } }) + .catch((error: unknown) => logger.warn({ guildId: guild.id, error: String(error) }, 'Welcome message not delivered')); + } + // Owners may have direct messages from server members turned off: that is + // their choice and the message is simply skipped. + const owner = await guild.fetchOwner().catch(() => null); + if (owner && !owner.user.bot) { + await owner.send({ embeds: [welcomeEmbed(locale, guild.id, guild.name, true)], allowedMentions: { parse: [] } }) + .catch((error: unknown) => logger.info({ guildId: guild.id, error: String(error) }, 'Welcome DM to the owner not delivered')); + } +} diff --git a/apps/web/app/(en)/en/[...missing]/page.tsx b/apps/web/app/(en)/en/[...missing]/page.tsx new file mode 100644 index 0000000..e799e58 --- /dev/null +++ b/apps/web/app/(en)/en/[...missing]/page.tsx @@ -0,0 +1,4 @@ +import { notFound } from 'next/navigation'; + +/** Any path without a page: hands over to the localized not-found page. */ +export default function Page() { notFound(); } diff --git a/apps/web/app/(en)/en/getting-started/page.tsx b/apps/web/app/(en)/en/getting-started/page.tsx new file mode 100644 index 0000000..7bfaea4 --- /dev/null +++ b/apps/web/app/(en)/en/getting-started/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import GettingStartedPage from '../../../_components/GettingStartedPage'; +import { pageMetadata } from '../../../_components/seo'; + +export const metadata: Metadata = pageMetadata({ + locale: 'en', + path: '/getting-started', + title: 'Getting started with Sentinel', + description: 'Add Sentinel to your Discord server and set up audit, moderation and message logs from the dashboard in five minutes: log channel, loggers, history and team access.' +}); + +export default function Page() { return ; } diff --git a/apps/web/app/(en)/en/not-found.tsx b/apps/web/app/(en)/en/not-found.tsx new file mode 100644 index 0000000..e11265f --- /dev/null +++ b/apps/web/app/(en)/en/not-found.tsx @@ -0,0 +1,3 @@ +import NotFoundPage from '../../_components/NotFoundPage'; + +export default function NotFound() { return ; } diff --git a/apps/web/app/(it)/it/[...missing]/page.tsx b/apps/web/app/(it)/it/[...missing]/page.tsx new file mode 100644 index 0000000..e799e58 --- /dev/null +++ b/apps/web/app/(it)/it/[...missing]/page.tsx @@ -0,0 +1,4 @@ +import { notFound } from 'next/navigation'; + +/** Any path without a page: hands over to the localized not-found page. */ +export default function Page() { notFound(); } diff --git a/apps/web/app/(it)/it/getting-started/page.tsx b/apps/web/app/(it)/it/getting-started/page.tsx new file mode 100644 index 0000000..1c298c2 --- /dev/null +++ b/apps/web/app/(it)/it/getting-started/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import GettingStartedPage from '../../../_components/GettingStartedPage'; +import { pageMetadata } from '../../../_components/seo'; + +export const metadata: Metadata = pageMetadata({ + locale: 'it', + path: '/getting-started', + title: 'Primi passi con Sentinel', + description: 'Aggiungi Sentinel al tuo server Discord e configura in cinque minuti dalla dashboard i log di audit, moderazione e messaggi: canale di log, logger, storico e accessi.' +}); + +export default function Page() { return ; } diff --git a/apps/web/app/(it)/it/not-found.tsx b/apps/web/app/(it)/it/not-found.tsx new file mode 100644 index 0000000..c756708 --- /dev/null +++ b/apps/web/app/(it)/it/not-found.tsx @@ -0,0 +1,3 @@ +import NotFoundPage from '../../_components/NotFoundPage'; + +export default function NotFound() { return ; } diff --git a/apps/web/app/(root)/[...missing]/page.tsx b/apps/web/app/(root)/[...missing]/page.tsx new file mode 100644 index 0000000..12204ae --- /dev/null +++ b/apps/web/app/(root)/[...missing]/page.tsx @@ -0,0 +1,4 @@ +import { notFound } from 'next/navigation'; + +/** Any path outside /en and /it without a page: English not-found page. */ +export default function Page() { notFound(); } diff --git a/apps/web/app/(root)/not-found.tsx b/apps/web/app/(root)/not-found.tsx new file mode 100644 index 0000000..36af68e --- /dev/null +++ b/apps/web/app/(root)/not-found.tsx @@ -0,0 +1,3 @@ +import NotFoundPage from '../_components/NotFoundPage'; + +export default function NotFound() { return ; } diff --git a/apps/web/app/_components/GettingStartedPage.tsx b/apps/web/app/_components/GettingStartedPage.tsx new file mode 100644 index 0000000..65e0ea3 --- /dev/null +++ b/apps/web/app/_components/GettingStartedPage.tsx @@ -0,0 +1,190 @@ +import { SiteFooter, SiteHeader } from './SiteChrome'; +import { StructuredData } from './StructuredData'; +import { SITE_URL } from './seo'; +import type { Locale } from '../i18n'; + +const SUPPORT_SERVER = 'https://discord.gg/DDBGWP5fMX'; + +type Step = { id: string; title: string; body: Array }; + +const copy: Record }; help: { title: string; text: string; support: string; issues: string }; +}> = { + en: { + kicker: 'Guide', + title: 'Getting started with Sentinel', + intro: 'Sentinel is set up entirely from the web dashboard: there are no commands to learn. These steps take about five minutes.', + toc: 'Steps', + home: 'Home', + pricing: 'Pricing', + dashboard: 'Dashboard', + add: 'Add to Discord', + open: 'Open the dashboard', + steps: [ + { id: 'add', title: '1. Add Sentinel to your server', body: [ + 'Use the “Add to Discord” button and choose the server. You need the Manage Server permission there.', + 'Discord shows the permissions Sentinel asks for. Keep them all: Sentinel is read-only and never needs Administrator.', + ['View Channels, Read Message History: to see the events to record;', 'Send Messages, Embed Links: to post logs in your log channels;', 'View Audit Log: to show who performed an action;', 'Change Nickname: used only by the Brand plan for a custom bot name.'] + ] }, + { id: 'dashboard', title: '2. Open the dashboard', body: [ + 'Sign in with Discord and pick your server. You see every server where you are the owner or have Administrator or Manage Server; other members can get Moderator or Viewer access if an admin links one of their roles.' + ] }, + { id: 'channel', title: '3. Choose a default log channel', body: [ + 'In Overview, choose the channel that receives logs by default. In that channel Sentinel must be able to view the channel, send messages and embed links.', + 'Tip: create a private channel visible only to your staff, for example #sentinel-logs.' + ] }, + { id: 'loggers', title: '4. Turn on the loggers you need', body: [ + 'In the Logger tab, loggers are grouped by area: members and moderation, messages, server structure, voice, content, automation and system.', + 'Each logger has two switches: collection stores the event in the history, delivery posts it to Discord. You can keep a full history without flooding your channels.', + 'Every logger can have its own channel, colour, title, role mentions, retention and exceptions for users, roles and channels, plus an “ignore bots” option.' + ] }, + { id: 'history', title: '5. Check the history', body: [ + 'History lists every recorded event with its delivery state, so you can see at once why a log did not reach Discord. You can filter by event, search by text or by a user or channel ID, and export to JSON.' + ] }, + { id: 'team', title: '6. Give access to your team', body: [ + 'In Administration, link Discord roles to the Viewer, Moderator or Admin levels. Levels are checked live against the server, so removing a role removes the access.' + ] } + ], + states: { title: 'Delivery states', rows: [ + ['SENT', 'Posted to Discord.'], + ['DISABLED', 'Delivery is off for that logger.'], + ['FILTERED', 'Skipped by an exception (ignored user, role or channel, or “ignore bots”).'], + ['NO_DESTINATION', 'The logger has no channel and there is no default log channel.'], + ['CHANNEL_UNAVAILABLE', 'The channel was deleted, or Sentinel cannot see it or write in it.'], + ['PREMIUM_REQUIRED', 'The logger is not included in the server’s plan.'], + ['FAILED', 'Discord rejected the message even after retries.'] + ] }, + help: { + title: 'Need help?', + text: 'Ask on the support server, including your server ID and the event you are looking at. Bugs and ideas go to GitHub.', + support: 'Support server', + issues: 'Report a bug or suggest a feature' + } + }, + it: { + kicker: 'Guida', + title: 'Primi passi con Sentinel', + intro: 'Sentinel si configura interamente dalla dashboard web: non ci sono comandi da imparare. Bastano circa cinque minuti.', + toc: 'Passaggi', + home: 'Home', + pricing: 'Prezzi', + dashboard: 'Dashboard', + add: 'Aggiungi a Discord', + open: 'Apri la dashboard', + steps: [ + { id: 'add', title: '1. Aggiungi Sentinel al server', body: [ + 'Usa il pulsante “Aggiungi a Discord” e scegli il server. Serve il permesso Gestisci server.', + 'Discord mostra i permessi richiesti da Sentinel. Lasciali tutti: Sentinel è in sola lettura e non ha mai bisogno di Amministratore.', + ['Visualizza canali, Leggi cronologia messaggi: per vedere gli eventi da registrare;', 'Invia messaggi, Incorpora link: per pubblicare i log nei canali scelti;', 'Visualizza registro attività: per mostrare chi ha eseguito un’azione;', 'Cambia nickname: usato solo dal piano Brand per il nome personalizzato del bot.'] + ] }, + { id: 'dashboard', title: '2. Apri la dashboard', body: [ + 'Accedi con Discord e scegli il server. Vedi tutti i server di cui sei proprietario o in cui hai Amministratore o Gestisci server; gli altri membri possono avere accesso Moderator o Viewer se un admin collega uno dei loro ruoli.' + ] }, + { id: 'channel', title: '3. Scegli un canale di log predefinito', body: [ + 'In Panoramica scegli il canale che riceve i log in modo predefinito. In quel canale Sentinel deve poter vedere il canale, inviare messaggi e incorporare link.', + 'Consiglio: crea un canale privato visibile solo allo staff, per esempio #sentinel-logs.' + ] }, + { id: 'loggers', title: '4. Attiva i logger che ti servono', body: [ + 'Nella scheda Logger i logger sono raggruppati per area: membri e moderazione, messaggi, struttura del server, voce, contenuti, automazioni e sistema.', + 'Ogni logger ha due interruttori: l’acquisizione salva l’evento nello storico, l’invio lo pubblica su Discord. Puoi tenere uno storico completo senza riempire i canali.', + 'Ogni logger può avere canale, colore, titolo, menzioni di ruoli, conservazione ed eccezioni per utenti, ruoli e canali, più l’opzione “ignora bot”.' + ] }, + { id: 'history', title: '5. Controlla lo storico', body: [ + 'Lo storico elenca ogni evento registrato con il suo stato di consegna, così vedi subito perché un log non è arrivato su Discord. Puoi filtrare per evento, cercare per testo o per ID di utente o canale ed esportare in JSON.' + ] }, + { id: 'team', title: '6. Dai accesso al tuo staff', body: [ + 'In Amministrazione collega i ruoli Discord ai livelli Viewer, Moderator o Admin. I livelli sono verificati in tempo reale sul server: togliendo il ruolo si toglie l’accesso.' + ] } + ], + states: { title: 'Stati di consegna', rows: [ + ['SENT', 'Pubblicato su Discord.'], + ['DISABLED', 'L’invio è disattivato per quel logger.'], + ['FILTERED', 'Saltato da un’eccezione (utente, ruolo o canale ignorato, oppure “ignora bot”).'], + ['NO_DESTINATION', 'Il logger non ha un canale e non c’è un canale di log predefinito.'], + ['CHANNEL_UNAVAILABLE', 'Il canale è stato eliminato, oppure Sentinel non può vederlo o scriverci.'], + ['PREMIUM_REQUIRED', 'Il logger non è incluso nel piano del server.'], + ['FAILED', 'Discord ha rifiutato il messaggio anche dopo i tentativi.'] + ] }, + help: { + title: 'Serve aiuto?', + text: 'Chiedi sul server di supporto indicando l’ID del server e l’evento che stai guardando. Bug e idee vanno su GitHub.', + support: 'Server di supporto', + issues: 'Segnala un bug o proponi una funzione' + } + } +}; + +function Paragraphs({ body }: { body: Step['body'] }) { + return <>{body.map((item, index) => Array.isArray(item) + ?
    {item.map((line) =>
  • {line}
  • )}
+ :

{item}

)}; +} + +export default function GettingStartedPage({ locale }: { locale: Locale }) { + const c = copy[locale]; + return ( +
+ ({ '@type': 'HowToStep', name: step.title.replace(/^\d+\.\s*/, ''), text: step.body.flat().join(' ') })) + }} /> + +
+
+ +
+
+ {c.kicker} +

{c.title}

+

{c.intro}

+ +
+
+ {c.steps.map((step) => ( +
+

{step.title}

+ +
+ ))} +
+

{c.states.title}

+
    + {c.states.rows.map(([state, text]) =>
  • {state} {text}
  • )} +
+
+
+

{c.help.title}

+

{c.help.text}

+ +
+
+
+
+
+ +
+ ); +} diff --git a/apps/web/app/_components/LegalPage.tsx b/apps/web/app/_components/LegalPage.tsx index 348e8be..07078f4 100644 --- a/apps/web/app/_components/LegalPage.tsx +++ b/apps/web/app/_components/LegalPage.tsx @@ -5,7 +5,7 @@ type Section = { title: string; body: Array }; type Doc = { id: string; kicker: string; title: string; intro: string; sections: Section[] }; const CONTACT = 'haxurus@icloud.com'; -const UPDATED = { it: '16 ottobre 2026', en: '16 October 2026' }; +const UPDATED = { it: '20 ottobre 2026', en: '20 October 2026' }; const copy: Record = { en: { @@ -38,6 +38,7 @@ const copy: Record + +
+
+ 404 +

{c.title}

+

{c.text}

+ +
+
+ + + ); +} diff --git a/apps/web/app/_components/PublicHome.tsx b/apps/web/app/_components/PublicHome.tsx index 4fb0663..da0e5ca 100644 --- a/apps/web/app/_components/PublicHome.tsx +++ b/apps/web/app/_components/PublicHome.tsx @@ -9,7 +9,7 @@ type StreamRow = { time: string; key: string; text: string; state: 'sent' | 'sto const copy = { it: { - nav: { features: 'Funzioni', pipeline: 'Pipeline', security: 'Sicurezza', openSource: 'Open source', pricing: 'Prezzi', signIn: 'Accedi', add: 'Aggiungi a Discord' }, + nav: { features: 'Funzioni', pipeline: 'Pipeline', security: 'Sicurezza', openSource: 'Open source', guide: 'Guida', pricing: 'Prezzi', signIn: 'Accedi', add: 'Aggiungi a Discord' }, hero: { kicker: 'Bot di log e audit log per Discord', title: ['Ogni azione', 'sul tuo server,', 'registrata.'], @@ -106,7 +106,7 @@ const copy = { footer: 'Logging e auditing self-hosted per server Discord.' }, en: { - nav: { features: 'Features', pipeline: 'Pipeline', security: 'Security', openSource: 'Open source', pricing: 'Pricing', signIn: 'Sign in', add: 'Add to Discord' }, + nav: { features: 'Features', pipeline: 'Pipeline', security: 'Security', openSource: 'Open source', guide: 'Guide', pricing: 'Pricing', signIn: 'Sign in', add: 'Add to Discord' }, hero: { kicker: 'Discord logging & audit log bot', title: ['Every action', 'on your server,', 'on the record.'], @@ -249,6 +249,7 @@ export default function PublicHome({ locale }: { locale: Locale }) { { href: '#features', label: c.nav.features }, { href: '#security', label: c.nav.security }, { href: '#open-source', label: c.nav.openSource }, + { href: `/${locale}/getting-started`, label: c.nav.guide }, { href: `/${locale}/pricing`, label: c.nav.pricing } ]} actions={<> @@ -420,6 +421,7 @@ export default function PublicHome({ locale }: { locale: Locale }) {