diff --git a/.gitignore b/.gitignore index 8c630a677..b8caff5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ go.work .idea/ *.wasm +.worktrees/ diff --git a/docs/superpowers/plans/2026-03-30-sign-in-page.md b/docs/superpowers/plans/2026-03-30-sign-in-page.md new file mode 100644 index 000000000..7703e0846 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-sign-in-page.md @@ -0,0 +1,816 @@ +# Sign-In Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the approved `/sign-in` page in `spx-gui`, route unauthenticated users through it, and keep authentication on the existing Casdoor redirect flow with safe post-login return handling. + +**Architecture:** Add a small sign-in entry utility layer for safe `returnTo` normalization and provider-aware redirect parameters, then wire router and existing entry points through a new `/sign-in` page. Keep the new page visually rich but behaviorally thin by splitting presentation into hero and panel components while leaving redirect logic in the auth store layer. + +**Tech Stack:** Vue 3, TypeScript, Vue Router 4, scoped SCSS, Vitest, Vue Test Utils, Casdoor JS SDK + +--- + +### Task 1: Sign-In Entry Utilities + +**Files:** +- Create: `spx-gui/src/stores/user/sign-in-entry.ts` +- Create: `spx-gui/src/stores/user/sign-in-entry.test.ts` +- Modify: `spx-gui/src/utils/env.ts:16-22,53-57` +- Modify: `spx-gui/src/stores/user/index.ts:1-7` +- Modify: `spx-gui/src/stores/user/signed-in.ts:12-75` + +- [ ] **Step 1: Record the new env keys in the documented template** + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildProviderParams, + getDefaultReturnTo, + getSignInRoute, + normalizeSafeReturnTo +} from './sign-in-entry' + +describe('sign-in-entry', () => { + const originalLocation = window.location + + beforeEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + origin: 'https://xbuilder.com', + pathname: '/project/alice/demo', + search: '?tab=play', + hash: '#runner' + } + }) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation + }) + }) + + it('keeps safe internal return targets', () => { + expect(normalizeSafeReturnTo('/editor/alice/demo?publish#share')).toBe('/editor/alice/demo?publish#share') + }) + + it('rejects unsafe return targets', () => { + expect(normalizeSafeReturnTo('https://evil.example')).toBe('/') + expect(normalizeSafeReturnTo('//evil.example')).toBe('/') + expect(normalizeSafeReturnTo('editor/alice/demo')).toBe('/') + }) + + it('builds the default return target from the current location', () => { + expect(getDefaultReturnTo()).toBe('/project/alice/demo?tab=play#runner') + }) + + it('builds a sign-in route with an encoded safe return target', () => { + expect(getSignInRoute('/project/alice/demo?tab=play#runner')).toBe( + '/sign-in?returnTo=%2Fproject%2Falice%2Fdemo%3Ftab%3Dplay%23runner' + ) + }) + + it('returns null provider params when a provider is not configured', () => { + expect(buildProviderParams({ providerParamName: 'provider', providerValue: '' })).toBeNull() + }) + + it('builds provider params when name and value are configured', () => { + expect(buildProviderParams({ providerParamName: 'provider', providerValue: 'wechat' })).toEqual({ + provider: 'wechat' + }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd spx-gui +npm test -- --run src/stores/user/sign-in-entry.test.ts +``` + +Expected: FAIL with module-not-found errors for `sign-in-entry.ts` exports. + +- [ ] **Step 3: Write the minimal implementation** + +`spx-gui/src/stores/user/sign-in-entry.ts` + +```ts +import { + casdoorProviderParamName, + casdoorQqProvider, + casdoorWeChatProvider +} from '@/utils/env' + +export function getDefaultReturnTo() { + return `${window.location.pathname}${window.location.search}${window.location.hash}` +} + +export function normalizeSafeReturnTo(input: string | null | undefined) { + if (input == null || input === '') return '/' + if (!input.startsWith('/')) return '/' + if (input.startsWith('//')) return '/' + return input +} + +export function getSignInRoute(returnTo: string = getDefaultReturnTo()) { + const safeReturnTo = normalizeSafeReturnTo(returnTo) + if (safeReturnTo === '/') return '/sign-in' + return `/sign-in?returnTo=${encodeURIComponent(safeReturnTo)}` +} + +export function buildProviderParams(config: { providerParamName: string; providerValue: string }) { + if (config.providerParamName === '' || config.providerValue === '') return null + return { [config.providerParamName]: config.providerValue } +} + +export function getWeChatProviderParams() { + return buildProviderParams({ + providerParamName: casdoorProviderParamName, + providerValue: casdoorWeChatProvider + }) +} + +export function getQQProviderParams() { + return buildProviderParams({ + providerParamName: casdoorProviderParamName, + providerValue: casdoorQqProvider + }) +} +``` + +`spx-gui/src/utils/env.ts` + +```ts +export const casdoorProviderParamName = (import.meta.env.VITE_CASDOOR_PROVIDER_PARAM_NAME as string) || 'provider' +export const casdoorWeChatProvider = (import.meta.env.VITE_CASDOOR_WECHAT_PROVIDER as string) || '' +export const casdoorQqProvider = (import.meta.env.VITE_CASDOOR_QQ_PROVIDER as string) || '' +``` + +`spx-gui/src/stores/user/signed-in.ts` + +```ts +import { getDefaultReturnTo, getQQProviderParams, getWeChatProviderParams, getSignInRoute } from './sign-in-entry' + +export function goToSignIn(returnTo: string = getDefaultReturnTo()) { + window.location.assign(getSignInRoute(returnTo)) +} + +export function initiateSignIn(returnTo: string = getDefaultReturnTo(), additionalParams?: Record) { + const casdoorSdk = new Sdk({ + ...casdoorConfig, + redirectPath: `${casdoorAuthRedirectPath}?returnTo=${encodeURIComponent(returnTo)}` + }) + casdoorSdk.signin_redirect(additionalParams) +} + +export function initiateWeChatSignIn(returnTo: string = getDefaultReturnTo()) { + initiateSignIn(returnTo, getWeChatProviderParams() ?? undefined) +} + +export function initiateQQSignIn(returnTo: string = getDefaultReturnTo()) { + initiateSignIn(returnTo, getQQProviderParams() ?? undefined) +} +``` + +`spx-gui/src/stores/user/index.ts` + +```ts +export * from './sign-in-entry' +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +cd spx-gui +npm test -- --run src/stores/user/sign-in-entry.test.ts +``` + +Expected: PASS with 6 passing tests and no import errors. + +- [ ] **Step 5: Commit** + +```bash +git add \ + spx-gui/src/utils/env.ts \ + spx-gui/src/stores/user/index.ts \ + spx-gui/src/stores/user/signed-in.ts \ + spx-gui/src/stores/user/sign-in-entry.ts \ + spx-gui/src/stores/user/sign-in-entry.test.ts +git commit -m "feat: add sign-in entry helpers" +``` + +### Task 2: Route `/sign-in` Through Router and Add the Page Shell + +**Files:** +- Create: `spx-gui/src/pages/sign-in/index.vue` +- Create: `spx-gui/src/pages/sign-in/index.test.ts` +- Modify: `spx-gui/src/router.ts:5,136-156,185-193` + +- [ ] **Step 1: Write the failing test** + +`spx-gui/src/pages/sign-in/index.test.ts` + +```ts +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const replace = vi.fn() +const initiateWeChatSignIn = vi.fn() +const initiateQQSignIn = vi.fn() +const initiateSignIn = vi.fn() +let signedInState = { isSignedIn: false, user: null as null | { username: string } } + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: { returnTo: '/project/alice/demo' } }), + useRouter: () => ({ replace }) +})) + +vi.mock('@/stores/user', () => ({ + useSignedInStateQuery: () => ({ + isLoading: { value: false }, + data: { value: signedInState } + }), + initiateWeChatSignIn, + initiateQQSignIn, + initiateSignIn +})) + +import SignInPage from './index.vue' + +describe('sign-in page shell', () => { + beforeEach(() => { + replace.mockReset() + initiateWeChatSignIn.mockReset() + initiateQQSignIn.mockReset() + initiateSignIn.mockReset() + signedInState = { isSignedIn: false, user: null } + }) + + it('renders the three approved sign-in actions', () => { + const wrapper = mount(SignInPage) + expect(wrapper.text()).toContain('XBuilder') + expect(wrapper.text()).toContain('登录') + expect(wrapper.text()).toContain('使用微信登录') + expect(wrapper.text()).toContain('使用 QQ 登录') + expect(wrapper.text()).toContain('用户名密码登录') + }) + + it('redirects signed-in visitors away from /sign-in', async () => { + signedInState = { isSignedIn: true, user: { username: 'alice' } } + mount(SignInPage) + await flushPromises() + expect(replace).toHaveBeenCalledWith('/project/alice/demo') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd spx-gui +npm test -- --run src/pages/sign-in/index.test.ts +``` + +Expected: FAIL because `src/pages/sign-in/index.vue` does not exist and `/sign-in` is not wired. + +- [ ] **Step 3: Write the minimal implementation** + +`spx-gui/src/pages/sign-in/index.vue` + +```vue + + + +``` + +`spx-gui/src/router.ts` + +```ts +import { getDefaultReturnTo, getSignInRoute, goToSignIn, isSignedIn, getUnresolvedSignedInUsername } from './stores/user' + +{ + path: '/sign-in', + component: () => import('@/pages/sign-in/index.vue') +}, + +if (username == null) { + return getSignInRoute(`/editor/${encodeURIComponent(projectNameInput as string)}`) +} + +router.beforeEach((to, _, next) => { + if (to.meta.requiresSignIn && !isSignedIn()) { + next(getSignInRoute(to.fullPath || getDefaultReturnTo())) + } else { + next() + } +}) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +cd spx-gui +npm test -- --run src/pages/sign-in/index.test.ts +``` + +Expected: PASS with page shell rendering and signed-in redirect behavior covered. + +- [ ] **Step 5: Commit** + +```bash +git add \ + spx-gui/src/router.ts \ + spx-gui/src/pages/sign-in/index.vue \ + spx-gui/src/pages/sign-in/index.test.ts +git commit -m "feat: add sign-in route shell" +``` + +### Task 3: Build the Final Figma Layout and Sign-In Components + +**Files:** +- Create: `spx-gui/src/components/sign-in/SignInHero.vue` +- Create: `spx-gui/src/components/sign-in/SignInPanel.vue` +- Create: `spx-gui/src/pages/sign-in/assets/sign-in-hero.svg` +- Create: `spx-gui/src/pages/sign-in/assets/icon-wechat.svg` +- Create: `spx-gui/src/pages/sign-in/assets/icon-qq.svg` +- Modify: `spx-gui/src/pages/sign-in/index.vue` +- Modify: `spx-gui/src/pages/sign-in/index.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append these assertions to `spx-gui/src/pages/sign-in/index.test.ts`: + +```ts +it('calls provider-specific helpers with the normalized return target', async () => { + const wrapper = mount(SignInPage) + + await wrapper.find('[data-testid="sign-in-wechat"]').trigger('click') + await wrapper.find('[data-testid="sign-in-qq"]').trigger('click') + await wrapper.find('[data-testid="sign-in-password"]').trigger('click') + + expect(initiateWeChatSignIn).toHaveBeenCalledWith('/project/alice/demo') + expect(initiateQQSignIn).toHaveBeenCalledWith('/project/alice/demo') + expect(initiateSignIn).toHaveBeenCalledWith('/project/alice/demo') +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd spx-gui +npm test -- --run src/pages/sign-in/index.test.ts +``` + +Expected: FAIL because the page does not yet expose the final test ids and split components. + +- [ ] **Step 3: Write the minimal implementation** + +`spx-gui/src/components/sign-in/SignInPanel.vue` + +```vue + + + +``` + +`spx-gui/src/components/sign-in/SignInHero.vue` + +```vue + + + +``` + +`spx-gui/src/pages/sign-in/index.vue` + +```vue + + + +``` + +Use scoped SCSS in `index.vue`, `SignInHero.vue`, and `SignInPanel.vue` to match the approved Figma structure: + +```scss +.page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: + radial-gradient(circle at top left, rgba(110, 151, 219, 0.22), transparent 42%), + radial-gradient(circle at bottom right, rgba(64, 186, 196, 0.2), transparent 38%), + #eff3fd; +} + +.card { + width: min(100%, 1000px); + min-height: 600px; + display: grid; + grid-template-columns: 1fr 1fr; + border-radius: 16px; + background: rgba(255, 255, 255, 0.8); + box-shadow: 0 16px 32px -12px rgba(64, 186, 196, 0.18); +} + +@media (max-width: 960px) { + .card { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +cd spx-gui +npm test -- --run src/pages/sign-in/index.test.ts +``` + +Expected: PASS with button click behavior covered after the component split. + +- [ ] **Step 5: Commit** + +```bash +git add \ + spx-gui/src/components/sign-in/SignInHero.vue \ + spx-gui/src/components/sign-in/SignInPanel.vue \ + spx-gui/src/pages/sign-in/assets/sign-in-hero.svg \ + spx-gui/src/pages/sign-in/assets/icon-wechat.svg \ + spx-gui/src/pages/sign-in/assets/icon-qq.svg \ + spx-gui/src/pages/sign-in/index.vue \ + spx-gui/src/pages/sign-in/index.test.ts +git commit -m "feat: implement sign-in page UI" +``` + +### Task 4: Rewire Existing Entry Points and Harden the Callback + +**Files:** +- Modify: `spx-gui/src/components/navbar/NavbarProfile.vue:3-10,82-90` +- Modify: `spx-gui/src/components/community/home/banner/GuestBanner.vue:1-8` +- Modify: `spx-gui/src/components/copilot/feedback/api-exception/SignInTip.vue:6-23` +- Modify: `spx-gui/src/components/community/user/UserHeader.vue:35-57` +- Modify: `spx-gui/src/pages/community/project.vue:358-378` +- Modify: `spx-gui/src/utils/user.ts:1-21` +- Create: `spx-gui/src/pages/sign-in/callback-utils.ts` +- Create: `spx-gui/src/pages/sign-in/callback-utils.test.ts` +- Modify: `spx-gui/src/pages/sign-in/callback.vue:7-24` + +- [ ] **Step 1: Write the failing test** + +`spx-gui/src/pages/sign-in/callback-utils.test.ts` + +```ts +import { describe, expect, it } from 'vitest' +import { getCallbackReturnTo } from './callback-utils' + +describe('getCallbackReturnTo', () => { + it('returns a normalized internal return target', () => { + expect(getCallbackReturnTo('?returnTo=%2Fproject%2Falice%2Fdemo')).toBe('/project/alice/demo') + }) + + it('falls back to / for unsafe return targets', () => { + expect(getCallbackReturnTo('?returnTo=https%3A%2F%2Fevil.example')).toBe('/') + }) + + it('falls back to / when returnTo is missing', () => { + expect(getCallbackReturnTo('')).toBe('/') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd spx-gui +npm test -- --run src/pages/sign-in/callback-utils.test.ts +``` + +Expected: FAIL because `callback-utils.ts` does not exist. + +- [ ] **Step 3: Write the minimal implementation** + +Replace direct `initiateSignIn()` call sites with `goToSignIn()` or `getSignInRoute(...)`-based routing: + +```ts +// NavbarProfile.vue +import { goToSignIn } from '@/stores/user' +@click="goToSignIn()" + +// GuestBanner.vue +import { goToSignIn } from '@/stores/user' +function handleJoin() { + goToSignIn() +} + +// SignInTip.vue +import { goToSignIn, isSignedIn } from '@/stores/user' + + +// utils/user.ts +import { goToSignIn, isSignedIn } from '@/stores/user' +confirmHandler: () => goToSignIn() + +// UserHeader.vue +import { goToSignIn, useSignedInUser } from '@/stores/user' +await timeout(2000) +goToSignIn() + +// community/project.vue +function handleSignIn() { + goToSignIn(route.fullPath) +} +``` + +`spx-gui/src/pages/sign-in/callback-utils.ts` + +```ts +import { normalizeSafeReturnTo } from '@/stores/user/sign-in-entry' + +export function getCallbackReturnTo(search: string) { + const params = new URLSearchParams(search) + return normalizeSafeReturnTo(params.get('returnTo')) +} +``` + +`spx-gui/src/pages/sign-in/callback.vue` + +```ts +import { completeSignIn } from '@/stores/user' +import { getCallbackReturnTo } from './callback-utils' + +try { + await completeSignIn() + const returnTo = getCallbackReturnTo(location.search) + window.location.replace(returnTo) +} catch (e) { + console.error('failed to complete sign-in', e) + window.location.replace('/') +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +cd spx-gui +npm test -- --run src/pages/sign-in/callback-utils.test.ts +npm test -- --run src/stores/user/sign-in-entry.test.ts src/pages/sign-in/index.test.ts +``` + +Expected: PASS for callback safety and no regressions in the new sign-in page behavior. + +- [ ] **Step 5: Commit** + +```bash +git add \ + spx-gui/src/components/navbar/NavbarProfile.vue \ + spx-gui/src/components/community/home/banner/GuestBanner.vue \ + spx-gui/src/components/copilot/feedback/api-exception/SignInTip.vue \ + spx-gui/src/components/community/user/UserHeader.vue \ + spx-gui/src/pages/community/project.vue \ + spx-gui/src/utils/user.ts \ + spx-gui/src/pages/sign-in/callback-utils.ts \ + spx-gui/src/pages/sign-in/callback-utils.test.ts \ + spx-gui/src/pages/sign-in/callback.vue +git commit -m "feat: route sign-in entry points through /sign-in" +``` + +### Task 5: Verify the End-to-End Baseline + +**Files:** +- Modify: `spx-gui/.env` (documentation only, if new vars are not yet listed) +- Verify only: `spx-gui/.env.development`, `spx-gui/.env.staging`, `spx-gui/.env.production` + +- [ ] **Step 1: Record the new env keys in the documented template** + +Add the new config keys to the documented `.env` template before any manual verification: + +```dotenv +VITE_CASDOOR_PROVIDER_PARAM_NAME="provider" +VITE_CASDOOR_WECHAT_PROVIDER="" +VITE_CASDOOR_QQ_PROVIDER="" +``` + +- [ ] **Step 2: Run focused verification before the final suite** + +Run: + +```bash +cd spx-gui +npm run type-check +npm test -- --run src/stores/user/sign-in-entry.test.ts src/pages/sign-in/index.test.ts src/pages/sign-in/callback-utils.test.ts +``` + +Expected: PASS on type-check and all new targeted tests. + +- [ ] **Step 3: Run the full test suite** + +Run: + +```bash +cd spx-gui +npm test -- --run +``` + +Expected: PASS with the project baseline still green. + +- [ ] **Step 4: Perform manual Figma verification** + +Checklist: + +```md +- Open `/sign-in` on desktop and compare against the approved Figma node +- Confirm the card is centered with the expected split layout +- Confirm the WeChat button is visually primary and QQ is secondary +- Confirm `用户名密码登录` routes to hosted Casdoor login +- Confirm removing provider env values still allows WeChat and QQ buttons to sign in via the generic flow +- Confirm visiting `/sign-in?returnTo=%2Fproject%2Falice%2Fdemo` after sign-in returns to `/project/alice/demo` +- Confirm unsafe `returnTo` values such as `https://evil.example` fall back to `/` +- Confirm narrow screens stack the panel above the illustration +``` + +- [ ] **Step 5: Commit** + +```bash +git add spx-gui/.env +git commit -m "docs: record sign-in provider env keys" +``` diff --git a/docs/superpowers/specs/2026-03-30-sign-in-page-design.md b/docs/superpowers/specs/2026-03-30-sign-in-page-design.md new file mode 100644 index 000000000..3ce14bf70 --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-sign-in-page-design.md @@ -0,0 +1,370 @@ +# Sign-In Page Design + +- Date: 2026-03-30 +- Status: Approved for planning +- Scope: `spx-gui` + +## Summary + +Implement the Figma domestic sign-in screen as the new first-party `/sign-in` page in `spx-gui`. +The page becomes the unified unauthenticated entry for routes and actions that require authentication. +Actual authentication continues to be handled by Casdoor. + +The page provides three explicit entry points: + +1. WeChat sign-in +2. QQ sign-in +3. Username/password sign-in via the existing Casdoor-hosted login page + +The design must match the provided Figma screen closely on desktop, while degrading cleanly to a stacked mobile layout. +The implementation must use the existing Vue 3 + TypeScript + scoped SCSS stack and existing UI primitives where practical. +No Tailwind or parallel styling system will be introduced. + +## Goals + +- Add a real `/sign-in` page that matches the approved Figma design. +- Route unauthenticated users to `/sign-in` instead of redirecting straight to Casdoor. +- Preserve `returnTo` so users return to their intended in-app destination after sign-in. +- Keep the authentication backend flow unchanged by continuing to use Casdoor PKCE redirect flow. +- Support provider-specific WeChat and QQ entry points when configured. +- Degrade WeChat and QQ buttons to generic Casdoor login when provider-specific config is unavailable. + +## Non-Goals + +- Build a first-party username/password form inside `spx-gui`. +- Change the token exchange flow in `/sign-in/callback` beyond safe redirect validation and user feedback. +- Rework unrelated auth, navbar, or community page visuals outside the sign-in entry flow. +- Introduce Tailwind, CSS-in-JS, or a new component library. + +## Context + +Today, most sign-in entry points call `initiateSignIn()` directly and jump to Casdoor immediately. +The current app has a callback page at `/sign-in/callback` and a token sign-in page at `/sign-in/token`, but no branded sign-in landing page. + +The approved Figma node is a desktop-first domestic login page with: + +- A soft blue atmospheric background +- A centered white translucent panel with rounded corners and subtle shadow +- A left illustration area +- A right sign-in action panel with XBuilder branding +- Three sign-in actions + +## Chosen Approach + +Create a new `/sign-in` route and page. +Any sign-in-required flow in the app should send users to `/sign-in?returnTo=...`. +The new page owns presentation and user choice of sign-in method. +Once the user chooses a method, the page calls a store-level auth helper that redirects to Casdoor with the proper parameters. + +This keeps authentication behavior centralized and reusable while making the user-facing experience align with the Figma design. + +## User Experience + +### Desktop + +- The page fills the viewport. +- A soft, layered, blue background sits behind the content. +- A centered main card is rendered at desktop scale, targeting the Figma proportions: + - Width: approximately `1000px` + - Height: approximately `600px` + - Two equal columns + - Radius: `16px` + - Subtle turquoise-tinted shadow +- Left side shows the illustration and page title. +- Right side shows the XBuilder logo, brand name, primary and secondary provider buttons, and the username/password entry text button. + +### Mobile and Narrow Widths + +- The page collapses into a single-column stacked layout. +- The sign-in panel is shown before the illustration. +- The main card width becomes fluid with safe viewport padding. +- The visual treatment remains branded, but layout prioritizes usability over strict desktop fidelity. + +### Interaction Rules + +- Clicking any login action puts that action into a short loading state and prevents duplicate submission. +- Username/password entry redirects to the generic Casdoor-hosted login. +- WeChat and QQ entry points try provider-specific redirect first, then fall back to generic Casdoor login when provider configuration is absent. + +## Route and Navigation Design + +## New Route + +- Add `/sign-in` +- Component: `@/pages/sign-in/index.vue` +- This route does not require sign-in. + +## Existing Routes + +- Keep `/sign-in/callback` +- Keep `/sign-in/token` + +## Already Signed-In Visitors + +If a signed-in user navigates to `/sign-in` directly, the page should not remain visible. +It should immediately redirect to the normalized `returnTo` target when present, otherwise `/`. + +## Redirect Flow + +When an unauthenticated user attempts an action or route that requires authentication: + +1. The app navigates to `/sign-in?returnTo=` +2. The user chooses a sign-in method +3. The app redirects to Casdoor +4. Casdoor returns to `/sign-in/callback?returnTo=...` +5. The callback exchanges the code for tokens +6. The callback safely redirects to `returnTo`, or `/` if unavailable or unsafe + +## Safe Redirect Rule + +`returnTo` must only allow an internal application path. + +Accepted: + +- `/` +- `/editor/foo/bar` +- `/project/a/b?x=1` +- `/user/name#section` + +Rejected and replaced with `/`: + +- Absolute URLs such as `https://example.com` +- Protocol-relative URLs such as `//evil.com` +- Empty malformed values that do not begin with `/` + +## Component Architecture + +## Page Component + +File: + +- `spx-gui/src/pages/sign-in/index.vue` + +Responsibilities: + +- Read and normalize `returnTo` +- Set page title +- Bind click handlers for three sign-in actions +- Coordinate loading state +- Render the composed page + +## Sign-In Panel Component + +File: + +- `spx-gui/src/components/sign-in/SignInPanel.vue` + +Responsibilities: + +- Render logo, brand name, provider buttons, and username/password entry action +- Emit `wechat`, `qq`, and `password` events +- Accept loading flags from parent +- Remain presentation-focused + +## Sign-In Hero Component + +File: + +- `spx-gui/src/components/sign-in/SignInHero.vue` + +Responsibilities: + +- Render the left-side title and illustration +- Own only visual presentation + +## Assets + +The left illustration should be committed as a stable local asset under the sign-in page asset directory rather than reconstructed from many inline Figma fragments. + +Planned location: + +- `spx-gui/src/pages/sign-in/assets/` + +This avoids brittle code, removes dependency on expiring remote Figma asset URLs, and preserves predictable rendering. + +## Authentication Helper Design + +Store-level auth helpers remain the single source of truth for redirect behavior. + +Enhance the auth helper layer in or adjacent to: + +- `spx-gui/src/stores/user/signed-in.ts` + +Required public behaviors: + +- Generic hosted login redirect +- WeChat login redirect with provider hint +- QQ login redirect with provider hint + +Recommended helper API: + +```ts +initiateSignIn(returnTo?: string, additionalParams?: Record) +initiateWeChatSignIn(returnTo?: string) +initiateQQSignIn(returnTo?: string) +goToSignIn(returnTo?: string) +normalizeSafeReturnTo(input: string | null | undefined): string +``` + +`initiateSignIn` remains the base primitive. +Provider-specific helpers wrap it and inject the configured Casdoor parameters. + +## Provider Configuration + +Provider hint values must not be hardcoded inside the page component. +They should come from environment-backed config so deployment environments can vary safely. + +Add optional environment variables: + +- `VITE_CASDOOR_WECHAT_PROVIDER` +- `VITE_CASDOOR_QQ_PROVIDER` +- `VITE_CASDOOR_PROVIDER_PARAM_NAME` + +Behavior: + +- If both provider param name and provider value are present, use provider-specific redirect. +- If either is missing, fall back to generic `initiateSignIn(returnTo)`. + +Default provider param name if unset: + +- `provider` + +This choice is explicit so the implementation is deterministic. +If the backend expects a different name in a future environment, deployment config can override it without code changes. + +## Entry Point Changes + +Existing user-visible sign-in entry points should navigate to the new page instead of calling Casdoor directly. + +Priority targets: + +- Global route guard behavior in `router.ts` +- Navbar sign-in button +- Any explicit "please sign in" CTA in community or project surfaces + +Rule: + +- If the user must choose a login method, navigate to `/sign-in` +- Do not redirect directly to Casdoor from ordinary UI buttons anymore + +Exception: + +- Low-level auth helpers remain available for the sign-in page and any special flows that intentionally bypass the chooser + +## Visual Design Translation + +## Styling System + +- Use scoped SCSS +- Reuse existing CSS variables where they fit +- Add local page-level custom properties only for values not already represented in the shared UI theme + +## Reuse of Existing UI Primitives + +- Reuse `UIButton` for the main provider buttons +- Reuse `UIIcon` only when a matching icon already exists +- Use local SVG or image assets for WeChat and QQ icons if the current icon set does not include them + +The login page may add wrapper classes around `UIButton` instances for width, radius, border, or typography alignment, but it must not change global button defaults in a way that affects unrelated pages. + +## Fidelity Priorities + +Priority order: + +1. Layout and spacing +2. Button hierarchy and typography +3. Card surface and background atmosphere +4. Illustration fidelity +5. Micro-details that would require invasive global UI changes + +## Accessibility + +- Buttons must remain semantic `