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
Binary file modified frontend/public/FrameSet_Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/public/FrameSet_Logo_Reversed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions frontend/public/llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# FrameSet

> FrameSet is a web app for illustrators and creatives that centralizes the
> graphic references of their projects — color palettes, typography and brush
> specifications — and turns them into shareable, exportable reference sheets.

## What you can do

- Build per-project color palettes by hand, with the system eyedropper, by
extracting colors from an image, by generating harmonies, or by importing
Adobe .ase, GIMP/Krita .gpl or Procreate .swatches files.
- Record typography standards (Google Fonts picker) and brush standards
(size, opacity, usage).
- Export a PDF style guide, JSON data, or palette files for drawing apps
(.ase, .gpl, .swatches), and share a public read-only link that updates
live as the project is edited.
- Accounts support email/password or Google sign-in, email verification,
optional TOTP two-factor authentication with recovery codes, and a
read-only demo account to try everything without registering.

## Pages

- [Home](/): product overview and features.
- [Register](/register): create an account (a demo is available without one).
- [Terms of Service](/terms) and [Privacy Policy](/privacy).

Shared reference sheets live under /s/<token> and are intentionally private
links (not indexed); the REST API under /api is documented for account
holders in the repository.
3 changes: 3 additions & 0 deletions frontend/src/components/Logo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ export default function Logo({ className = '', style }) {
alt="FrameSet"
className={`${className} dark:hidden`.trim()}
style={style}
// Often the page's LCP element (public top bar): fetch it eagerly.
fetchpriority="high"
/>
<img
src="/FrameSet_Logo_Reversed.png"
alt="FrameSet"
className={`${className} hidden dark:block`.trim()}
style={style}
fetchpriority="high"
/>
</>
);
Expand Down
54 changes: 53 additions & 1 deletion frontend/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ export const AuthContext = createContext(null);
const SESSION_WARNING_BEFORE_MS = 10 * 60 * 1000;
const SESSION_CHECK_INTERVAL_MS = 60 * 1000;

// localStorage flag marking that a session was opened on this browser, so the
// mount-time profile probe can be skipped for plain visitors (who would only
// get a 401). Purely an optimization hint — never trusted as authentication;
// localStorage may be unavailable (private mode), hence the try/catch shells.
const SESSION_HINT_KEY = 'frameset-session';
const hasSessionHint = () => {
try {
return localStorage.getItem(SESSION_HINT_KEY) === '1';
} catch {
// Can't read the hint: fall back to always probing, like before.
return true;
}
};
const setSessionHint = () => {
try {
localStorage.setItem(SESSION_HINT_KEY, '1');
} catch {
/* cosmetic only */
}
};
const clearSessionHint = () => {
try {
localStorage.removeItem(SESSION_HINT_KEY);
} catch {
/* cosmetic only */
}
};

export const AuthProvider = ({ children }) => {
// Authenticated user (null when logged out). authLoading is true until the
// initial session hydration completes, so guards can avoid flashing.
Expand Down Expand Up @@ -69,6 +97,12 @@ export const AuthProvider = ({ children }) => {
// On mount, restore the session from the auth cookies by fetching the profile.
// Handles three cases: valid session, no session (401), and expired access
// token (403 -> attempt one refresh, then retry the profile fetch once).
//
// The probe only runs when a session hint is present (set below whenever a
// session actually opens): first-time visitors on public pages get zero
// profile request instead of a guaranteed 401 — faster, and no error noise
// in the console. The hint is only ever an optimization signal; the cookies
// remain the single source of truth.
useEffect(() => {
let isMounted = true;

Expand All @@ -85,14 +119,25 @@ export const AuthProvider = ({ children }) => {
};

const hydrateSession = async () => {
// No hint of a previous session on this browser: skip the probe, the
// visitor is signed out (a stale-cookie edge would just require signing
// in again, exactly like an expired session).
if (!hasSessionHint()) {
setHydratedUser(null);
if (isMounted) setAuthLoading(false);
return;
}

try {
// skipTokenRefresh: we handle the 403/refresh flow explicitly below.
const profile = await api.get('/users/profile', { skipTokenRefresh: true });
setHydratedUser(profile || null);
return;
} catch (error) {
// 401: no session at all -> remain logged out.
// 401: no session at all -> remain logged out (and drop the stale hint
// so the next visit skips the probe again).
if (error?.status === 401) {
clearSessionHint();
setHydratedUser(null);
return;
}
Expand Down Expand Up @@ -148,6 +193,12 @@ export const AuthProvider = ({ children }) => {
return () => setSessionExpiredHandler(null);
}, [setGlobalError]);

// Any signed-in state (fresh login of any kind, or a successful hydration)
// marks the browser as having a session, so the next visit probes for it.
useEffect(() => {
if (user) setSessionHint();
}, [user]);

// Whenever the session is renewed anywhere (this provider's own refresh calls,
// or the reactive silent refresh inside services/api.js triggered by a random
// request hitting a 403), reset the "expires at" estimate the same way.
Expand Down Expand Up @@ -334,6 +385,7 @@ export const AuthProvider = ({ children }) => {
} finally {
setUser(null);
setGlobalError(null);
clearSessionHint();
sessionExpiresAtRef.current = null;
setSessionExpiringSoon(false);
}
Expand Down
61 changes: 61 additions & 0 deletions frontend/tests/unit/AuthContext.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, waitFor, act, renderHook } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AuthProvider, useAuth } from '../../src/context/AuthContext';

const {
Expand Down Expand Up @@ -76,6 +77,15 @@ const buildHttpError = (status, message = 'HTTP error') => {
return error;
};

// The mount-time profile probe only runs when the session hint is present:
// unless a test says otherwise, simulate a browser that had a session.
beforeEach(() => {
localStorage.setItem('frameset-session', '1');
});
afterEach(() => {
localStorage.clear();
});

describe('AuthContext session hydration', () => {
beforeEach(() => {
mockApiGet.mockReset();
Expand All @@ -87,6 +97,57 @@ describe('AuthContext session hydration', () => {
mockSetSessionRefreshedHandler.mockClear();
});

it('skips the profile probe entirely on a browser with no session hint', async () => {
localStorage.clear();
renderProvider();

await waitFor(() => expect(screen.getByTestId('auth-loading')).toHaveTextContent('false'));

expect(mockApiGet).not.toHaveBeenCalled();
expect(screen.getByTestId('user-email')).toHaveTextContent('');
});

it('drops the hint after a hard 401 so the next visit skips the probe too', async () => {
mockApiGet.mockRejectedValueOnce(buildHttpError(401));
renderProvider();

await waitFor(() => expect(screen.getByTestId('auth-loading')).toHaveTextContent('false'));

expect(mockApiGet).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('frameset-session')).toBeNull();
});

it('sets the hint when a session opens and clears it on logout', async () => {
localStorage.clear();
const Actions = () => {
const { login, logout } = useAuth();
return (
<div>
<button type="button" onClick={() => login('axelle@example.com', 'Pass1234')}>
do-login
</button>
<button type="button" onClick={() => logout()}>
do-logout
</button>
</div>
);
};
render(
<AuthProvider>
<Actions />
</AuthProvider>,
);
const user = userEvent.setup();

mockApiPost.mockResolvedValueOnce({ success: true, id: 1, name: 'Axelle' });
await user.click(screen.getByRole('button', { name: 'do-login' }));
await waitFor(() => expect(localStorage.getItem('frameset-session')).toBe('1'));

mockApiPost.mockResolvedValueOnce({ success: true });
await user.click(screen.getByRole('button', { name: 'do-logout' }));
await waitFor(() => expect(localStorage.getItem('frameset-session')).toBeNull());
});

it('hydrates the user when /profile returns 200', async () => {
mockApiGet.mockResolvedValueOnce({ id: 1, email: 'axelle@example.fr' });

Expand Down
Loading