Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`GET/PUT /api/guilds/:guildId/rbac` and `GET /api/guilds/:id/me`
- Added frontend Access Control section in Server Settings to manage module
grants by Discord role as full-policy replacement
- Added public legal routes for Discord app metadata:
`/terms-of-service`, `/privacy-policy` with aliases `/terms`, `/privacy`
- Added public install redirect endpoint (`/api/install`) and canonical install
link (`https://lucky.lucassantana.tech/install`) for Discord app installation
- Added Discord Activities URL mapping policy for embedded app setup:
root prefix `/` targets `lucky.lucassantana.tech` with no proxy path mappings

### Fixed

Expand All @@ -43,6 +49,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Frontend shell now initializes guild selection on all authenticated routes,
so the server selector is populated right after login instead of only after
visiting pages that manually triggered guild loading (PR #162)
- Vercel deep links now use a final SPA fallback rewrite after `/api` and
`/install`, and README now documents the complete Discord portal URL mapping
(General Information, Installation, and Activities URL Mappings)
- `/install` now proxies to `/api/auth/discord` so the public install URL
reliably returns Discord OAuth redirect (`302`) on production

### Changed

Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,22 @@ Set `WEBAPP_EXPECTED_CLIENT_ID` to the production Discord app id to make
Set `WEBAPP_BACKEND_URL` to your public backend/API origin when you expose API routes
through a dedicated host.

Discord Developer Portal URL mapping for this deployment:

- General Information:
- Interaction Endpoint URL: leave empty
- Linked Roles Verification URL: leave empty
- Terms of Service URL: `https://lucky.lucassantana.tech/terms-of-service`
- Privacy Policy URL: `https://lucky.lucassantana.tech/privacy-policy`
- Installation:
- Installation Link (custom): `https://lucky.lucassantana.tech/install`
- Install redirect target: `/api/auth/discord`
- Install contexts: Guild Install enabled, User Install disabled
- Activities -> URL Mappings:
- Root mapping prefix: `/`
- Root mapping target: `lucky.lucassantana.tech`
- Proxy path mappings: none (leave empty)

## Environment Variables

See `.env.example` for all available options. Key variables:
Expand Down
74 changes: 74 additions & 0 deletions packages/frontend/src/App.legalRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import App from './App'
import { useAuthStore } from '@/stores/authStore'

vi.mock('@/stores/authStore')

describe('App legal routes', () => {
beforeEach(() => {
vi.clearAllMocks()

vi.mocked(useAuthStore).mockImplementation((selector) => {
const store = {
user: null,
isAuthenticated: false,
isLoading: false,
isDeveloper: false,
login: vi.fn(),
logout: vi.fn(),
checkAuth: vi.fn().mockResolvedValue(undefined),
checkDeveloperStatus: vi.fn(),
}

return selector ? selector(store) : store
})
})

function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
)
}

test('renders terms page for canonical path while unauthenticated', async () => {
renderAt('/terms-of-service')

expect(
await screen.findByRole('heading', { name: /terms of service/i }),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /login with discord/i }),
).not.toBeInTheDocument()
})

test('renders privacy page for canonical path while unauthenticated', async () => {
renderAt('/privacy-policy')

expect(
await screen.findByRole('heading', { name: /privacy policy/i }),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /login with discord/i }),
).not.toBeInTheDocument()
})

test('renders terms page for alias path', async () => {
renderAt('/terms')

expect(
await screen.findByRole('heading', { name: /terms of service/i }),
).toBeInTheDocument()
})

test('renders privacy page for alias path', async () => {
renderAt('/privacy')

expect(
await screen.findByRole('heading', { name: /privacy policy/i }),
).toBeInTheDocument()
})
})
43 changes: 42 additions & 1 deletion packages/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
useState,
type ReactNode,
} from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { ShieldAlert } from 'lucide-react'
import ErrorBoundary from '@/components/ErrorBoundary'
import { useAuthStore } from './stores/authStore'
Expand Down Expand Up @@ -35,6 +35,19 @@
() => import('./pages/TwitchNotifications'),
)
const LastFmPage = lazy(() => import('./pages/LastFm'))
const TermsOfServicePage = lazy(() => import('./pages/TermsOfService'))
const PrivacyPolicyPage = lazy(() => import('./pages/PrivacyPolicy'))

const LEGAL_PATHS = [

Check warning on line 41 in packages/frontend/src/App.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`LEGAL_PATHS` should be a `Set`, and use `LEGAL_PATHS.has()` to check existence or non-existence.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZze_2ToS4YnTyer-eyu&open=AZze_2ToS4YnTyer-eyu&pullRequest=168
'/terms-of-service',
'/terms',
'/privacy-policy',
'/privacy',
]

function isLegalPath(pathname: string) {
return LEGAL_PATHS.includes(pathname)
}

function ForbiddenModulePage({ module }: { module: ModuleKey }) {
return (
Expand Down Expand Up @@ -150,7 +163,23 @@
)
}

function LegalRoutes() {
return (
<Routes>
<Route path='/terms-of-service' element={<TermsOfServicePage />} />
<Route path='/terms' element={<TermsOfServicePage />} />
<Route path='/privacy-policy' element={<PrivacyPolicyPage />} />
<Route path='/privacy' element={<PrivacyPolicyPage />} />
<Route
path='*'
element={<Navigate to='/terms-of-service' replace />}
/>
</Routes>
)
}

function App() {
const location = useLocation()
const { isAuthenticated, isLoading, checkAuth } = useAuthStore()
const [isReady, setIsReady] = useState(false)
const initialized = useRef(false)
Expand All @@ -164,6 +193,18 @@
.catch(() => setIsReady(true))
}, [checkAuth])

if (isLegalPath(location.pathname)) {
return (
<div className='dark'>
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<LegalRoutes />
</Suspense>
</ErrorBoundary>
</div>
)
}

// Show loader while initializing
if (!isReady || isLoading) {
return (
Expand Down
58 changes: 58 additions & 0 deletions packages/frontend/src/pages/PrivacyPolicy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export default function PrivacyPolicyPage() {
return (
<main className='min-h-screen bg-lucky-bg px-6 py-10 text-lucky-text-primary'>
<div className='mx-auto w-full max-w-4xl space-y-8'>
<header className='space-y-3'>
<p className='type-meta text-lucky-text-tertiary'>Legal</p>
<h1 className='type-h1'>Privacy Policy</h1>
<p className='type-body text-lucky-text-secondary'>
Last updated: March 11, 2026
</p>
</header>

<section className='space-y-3'>
<h2 className='type-h2'>Data we collect</h2>
<p className='type-body text-lucky-text-secondary'>
Lucky processes Discord account identifiers and server
configuration data required to provide bot features and
dashboard access.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>How we use data</h2>
<p className='type-body text-lucky-text-secondary'>
Data is used to authenticate users, manage server
settings, and operate integrations requested by server
administrators.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Data sharing and retention</h2>
<p className='type-body text-lucky-text-secondary'>
Lucky does not sell personal data. Data is retained only
as needed for service operation, security, and legal
obligations.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Contact</h2>
<p className='type-body text-lucky-text-secondary'>
For privacy requests, open an issue at{' '}
<a
className='text-lucky-accent underline'
href='https://github.com/LucasSantana-Dev/Lucky/issues'
rel='noreferrer'
target='_blank'
>
https://github.com/LucasSantana-Dev/Lucky/issues
</a>

Check warning on line 51 in packages/frontend/src/pages/PrivacyPolicy.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element a

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZze_2TYS4YnTyer-eyt&open=AZze_2TYS4YnTyer-eyt&pullRequest=168
.
</p>
</section>
</div>
</main>
)
}
59 changes: 59 additions & 0 deletions packages/frontend/src/pages/TermsOfService.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export default function TermsOfServicePage() {
return (
<main className='min-h-screen bg-lucky-bg px-6 py-10 text-lucky-text-primary'>
<div className='mx-auto w-full max-w-4xl space-y-8'>
<header className='space-y-3'>
<p className='type-meta text-lucky-text-tertiary'>Legal</p>
<h1 className='type-h1'>Terms of Service</h1>
<p className='type-body text-lucky-text-secondary'>
Last updated: March 11, 2026
</p>
</header>

<section className='space-y-3'>
<h2 className='type-h2'>Service scope</h2>
<p className='type-body text-lucky-text-secondary'>
Lucky provides Discord bot features and a dashboard for
server management. By using Lucky, you agree to these
terms.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Acceptable use</h2>
<p className='type-body text-lucky-text-secondary'>
You must use Lucky in compliance with Discord terms,
applicable laws, and your server rules. Abuse, misuse,
or attempts to disrupt the service are prohibited.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Availability and changes</h2>
<p className='type-body text-lucky-text-secondary'>
Lucky is provided on an as-is basis. Features may be
updated, limited, or removed to improve reliability,
security, and compliance.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Contact</h2>
<p className='type-body text-lucky-text-secondary'>
For support or legal requests, use the official issue
tracker:{' '}
<a
className='text-lucky-accent underline'
href='https://github.com/LucasSantana-Dev/Lucky/issues'
rel='noreferrer'
target='_blank'
>
https://github.com/LucasSantana-Dev/Lucky/issues
</a>

Check warning on line 52 in packages/frontend/src/pages/TermsOfService.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element a

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZze_2QMS4YnTyer-eys&open=AZze_2QMS4YnTyer-eys&pullRequest=168
.
</p>
</section>
</div>
</main>
)
}
8 changes: 8 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
{
"source": "/api/:path*",
"destination": "https://lucky-api.lucassantana.tech/api/:path*"
},
{
"source": "/install",
"destination": "https://lucky-api.lucassantana.tech/api/auth/discord"
},
{
"source": "/:path*",
"destination": "/index.html"
}
]
}
Loading