Tier model: registered=basic (recipes, 12 photos, QR), PRO=verified or admin-activated (HSL, tools/gradient mask, RAW, export > source longest edge); admin users table gets Activated Pro column

This commit is contained in:
2026-09-30 11:50:28 +07:00
parent 35d8d57984
commit 74c0b2747f
10 changed files with 264 additions and 122 deletions
+26 -4
View File
@@ -172,6 +172,17 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
}
}
// PRO, the operator's own switch: the admin ticks it in the users table and the
// account reads the studio's PRO features from then on, proven address or not.
// It only ever adds to what a verified account already has — unticking it
// cannot take the tier away from an address that has been proven.
{
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'pro')) {
db.exec(`ALTER TABLE users ADD COLUMN pro INTEGER NOT NULL DEFAULT 0`);
}
}
// The mailed code, added once visitors were expected to prove an address
// without leaving the page. Rows minted before it keep working as links: their
// `code` is NULL, which no typed guess can match (see verifyEmailCode).
@@ -228,6 +239,8 @@ export const PHOTO_HISTORY_MAX = 3;
// `avatar` is the stored file name, or null for "no picture".
// `emailVerified` is 0/1 from SQLite; the route layer turns it into the
// `verified` the client reads.
// `pro` is the admin's own 0/1 grant — the "Activated Pro" box in the users
// table, and the only way an unproven address reaches the PRO tier.
export type User = {
id: number;
email: string;
@@ -235,6 +248,7 @@ export type User = {
blocked: number;
deletedAt: string | null;
emailVerified: number;
pro: number;
};
export type Recipe = {
id: number;
@@ -271,7 +285,7 @@ export function createUser(email: string, password: string): User | null {
const info = db
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
.run(email, hashPassword(password), now());
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0 };
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0 };
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
throw err;
@@ -281,14 +295,14 @@ export function createUser(email: string, password: string): User | null {
export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
return db
.prepare(
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, password_hash FROM users WHERE email = ?',
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, password_hash FROM users WHERE email = ?',
)
.get(email) as (User & { password_hash: string }) | undefined;
}
export function findUserById(id: number): User | undefined {
return db
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified FROM users WHERE id = ?')
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro FROM users WHERE id = ?')
.get(id) as User | undefined;
}
@@ -589,6 +603,7 @@ export type AdminUser = {
avatar: string | null;
blocked: number;
deletedAt: string | null;
pro: number;
};
export function listUsersWithCounts(): AdminUser[] {
@@ -596,7 +611,7 @@ export function listUsersWithCounts(): AdminUser[] {
.prepare(
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
users.avatar AS avatar, users.blocked AS blocked,
users.deleted_at AS deletedAt, COUNT(photos.id) AS photos
users.deleted_at AS deletedAt, users.pro AS pro, COUNT(photos.id) AS photos
FROM users LEFT JOIN photos ON photos.user_id = users.id
GROUP BY users.id
ORDER BY users.id`,
@@ -613,6 +628,13 @@ export function setUserBlocked(id: number, blocked: boolean): boolean {
return info.changes > 0;
}
// The PRO grant, ticked or unticked from the users table. No session sweep: the
// tier is read off the row on every request, so the next one already sees it.
export function setUserPro(id: number, pro: boolean): boolean {
const info = db.prepare('UPDATE users SET pro = ? WHERE id = ?').run(pro ? 1 : 0, id);
return info.changes > 0;
}
export function setUserRemoved(id: number, removed: boolean): boolean {
const info = db
.prepare('UPDATE users SET deleted_at = ? WHERE id = ?')
+59 -27
View File
@@ -52,6 +52,7 @@ import {
setUserAvatar,
setUserBlocked,
setUserPassword,
setUserPro,
setUserRemoved,
topRatedPhotos,
updateRecipe,
@@ -87,17 +88,26 @@ const ADMIN_EMAILS = new Set(
);
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
// What an account is worth. A signup proves nothing until the address it gave
// is confirmed, so an unverified account is a guest with a name: the PRO tier,
// its own listings and every write stay shut. Admins come from the
// deployment's own allowlist — trusted by construction, so no letter is needed
// and a broken relay cannot lock the operator out of their own site.
// What an account is worth. Being signed in is the basic tier and nothing more
// is required of it (see requireMember): the account has its own recipes and
// its own photo folder. This is the proof of address, which the PRO tier still
// carries on top. Admins come from the deployment's own allowlist — trusted by
// construction, so no letter is needed and a broken relay cannot lock the
// operator out of their own site.
const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user);
// The PRO tier itself: a proven address, an admin, or a grant the operator
// ticked in the users table. The grant only ever adds — unticking an account
// that has already proven its address leaves it PRO, because the address is
// still the stronger proof of the two.
const isPro = (user: User) => isVerified(user) || user.pro === 1;
// The public shape of an account. `admin` is the allowlist's answer, so the
// client can decide whether to offer /admin without a second round trip — and
// the server still enforces it on every admin route below.
// `verified` is the studio's PRO gate: true only for a proven address.
// `verified` is the proof of address (the client offers the resend/verify
// routes off it); `pro` is the studio's own gate, which is that proof or the
// operator's grant.
// `avatar` is a URL the client can drop straight into an <img>, or null when
// the account never picked a picture. The `v` is the stored file's own name, so
// the URL changes with the picture and can be cached hard.
@@ -106,6 +116,7 @@ const publicUser = (user: User) => ({
email: user.email,
admin: isAdmin(user),
verified: isVerified(user),
pro: isPro(user),
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
});
@@ -253,17 +264,29 @@ function auth(req: FastifyRequest): User | undefined {
return token ? sessionUser(token) : undefined;
}
// The gate every personal route takes instead of `auth`. Two different
// refusals, because the studio acts on them differently: 401 sends a guest to
// the sign-in dialog, 403 asks a signed-in account to open its mail.
// The gate every personal route takes instead of `auth`: the basic tier is
// simply being signed in — recipes, the photo folder, an export of the photo
// being edited. One refusal, because the studio acts on it the one way: 401
// sends a guest to the sign-in dialog.
function requireMember(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
return user;
}
// The PRO tier's own gate, on the routes that are the tier: the geocoder is the
// one left (every other route that used to take it is the basic tier's now).
function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
if (!isVerified(user)) {
void reply.status(403).send({ error: 'email not verified' });
if (!isPro(user)) {
void reply.status(403).send({ error: 'pro required' });
return undefined;
}
return user;
@@ -498,7 +521,7 @@ app.get('/api/auth/verify', async (req, reply) => {
// fresh code costs one of the three resends an hour, so five guesses per code
// is the budget an attacker has either way — and the cap lives with the secret.
app.post('/api/auth/verify-code', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// `auth`, not `requireMember`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -518,7 +541,7 @@ app.post('/api/auth/verify-code', async (req, reply) => {
const allowResend = limiter(3, 60 * 60_000);
app.post('/api/auth/resend-verification', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// `auth`, not `requireMember`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -564,7 +587,7 @@ app.get('/api/auth/me', async (req, reply) => {
// password is required either way, so a stolen cookie alone cannot lock the
// owner out — and the login limiter caps guesses at it.
app.patch('/api/auth/me', async (req, reply) => {
// `auth`, not `requirePro`: editing your own profile is how an unverified
// `auth`, not `requireMember`: editing your own profile is how an unverified
// account fixes a mistyped address, so this route stays open to it.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -601,13 +624,13 @@ app.patch('/api/auth/me', async (req, reply) => {
});
app.get('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
return reply.status(200).send({ recipes: listRecipes(user.id) });
});
app.post('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const b = bodyOf(req);
const payload = b && recipePayload(b);
@@ -618,7 +641,7 @@ app.post('/api/recipes', async (req, reply) => {
});
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
@@ -632,7 +655,7 @@ app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
});
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
@@ -692,7 +715,7 @@ app.get('/api/photos', async () => ({ photos: listPhotos() }));
// The caller's own folder — the count the studio's SAVE PHOTO shows comes from
// here, and the admin drill-down reads the same rows through /admin/photos.
app.get('/api/photos/mine', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
@@ -712,7 +735,7 @@ app.get('/api/place', async (req, reply) => {
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -743,7 +766,7 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
@@ -773,7 +796,7 @@ app.put<{ Params: { id: string } }>(
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
// under a server-generated name. The picture it replaces goes with it.
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -849,7 +872,7 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id/base',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const id = Number(req.params.id);
@@ -874,7 +897,7 @@ app.put<{ Params: { id: string } }>(
);
app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
@@ -921,7 +944,7 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req,
// consent), and only their own row is reachable — the user_id in the WHERE is
// the authorisation.
app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
@@ -936,7 +959,7 @@ app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
// dropped when the caller owns it (or curates the whole strip), and the file
// goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink.
app.delete<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
@@ -988,6 +1011,9 @@ app.get('/api/admin/users', async (req, reply) => {
admin: ADMIN_EMAILS.has(u.email),
blocked: !!u.blocked,
removed: !!u.deletedAt,
// An allowlisted account is PRO by construction; its stored box is not
// what decides, so the table shows the truth of `isPro`.
pro: !!u.pro || ADMIN_EMAILS.has(u.email.toLowerCase()),
})),
});
});
@@ -1029,8 +1055,14 @@ app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply)
if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' });
setUserRemoved(id, b.removed);
}
if (b.pro !== undefined) {
if (typeof b.pro !== 'boolean') return reply.status(400).send({ error: 'invalid pro' });
setUserPro(id, b.pro);
}
const row = listUsersWithCounts().find((u) => u.id === id);
return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } });
return reply
.status(200)
.send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt, pro: !!row?.pro } });
});
app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {
+20 -3
View File
@@ -183,9 +183,10 @@ export function Admin() {
setAlbum(userId);
};
// Block/unblock and remove/restore share one route; the list is reloaded
// because either flag also changes what the built-in viewer would show.
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
// Block/unblock, remove/restore and the PRO grant share one route; the list
// is reloaded because any of the flags also changes what the built-in viewer
// would show.
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
run(async () => {
await api.adminSetUser(id, patch);
await refreshPhotos();
@@ -604,6 +605,7 @@ export function Admin() {
<th>{t('adm.userName')}</th>
<th>{t('adm.userEmail')}</th>
<th>{t('adm.userRole')}</th>
<th className="adm-pro">{t('adm.userPro')}</th>
<th>{t('adm.userPhotos')}</th>
<th>{t('adm.userJoined')}</th>
<th />
@@ -644,6 +646,21 @@ export function Admin() {
{u.admin ? t('adm.roleAdmin') : t('adm.roleMember')}
</span>
</td>
<td className="adm-pro">
{/* The PRO grant. Ticking it hands this account the
studio's PRO tier on its next request; the API
reads the row, not the session, so nothing has
to be signed in again. An admin account is PRO
by allowlist, so its box only reports that. */}
<input
type="checkbox"
data-key={`adm-user-pro-${u.id}`}
aria-label={t('adm.userPro')}
checked={u.pro}
disabled={busy || u.admin}
onChange={() => void setUserState(u.id, { pro: !u.pro })}
/>
</td>
<td>{u.photos}</td>
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
<td>
+76 -60
View File
@@ -545,11 +545,16 @@ export function Workspace() {
const [choosingExport, setChoosingExport] = useState(false);
const [savingPhoto, setSavingPhoto] = useState(false);
const [user, setUser] = useState<User | null>(null);
// PRO is the old "signed in", and then some: the account has to have proven
// the address it signed up with. Until it does, this build serves it exactly
// like a guest — watermarked 2048px export, no saving, no PRO tools. An
// allowlisted admin is trusted by construction and counts as verified.
const pro = !!user?.verified;
// Three tiers, one account each (see config/tiers). Signed in is the basic
// tier: recipes, the photo folder, a clean export of the photo it is editing.
// PRO is the box the operator ticks in the admin users table — HSL, TOOLS,
// GRADIENT MASK, RAW, and an export past the photo's own pixels. An
// allowlisted admin is the operator, so it counts as PRO by construction.
const member = !!user;
const pro = !!user?.pro;
// The export ceiling the menu and the file both obey: none for PRO, the
// photo's own long edge for a basic account, the tier's 2048 for a guest.
const exportCap = pro ? null : member ? editedLongest : TIERS.guest.maxExportDim;
// Admins are the server's own operators: the API skips MAX_PHOTOS_PER_USER
// for them (server.ts), so the studio stops counting at the same place. null
// means "no cap" all the way down to SAVE PHOTO's label.
@@ -616,9 +621,16 @@ export function Workspace() {
});
// What every PRO-only entry point calls instead of returning silently: a
// guest is sent to the dialog for an account, an unverified account to the
// same dialog for its letter.
const promptPro = useCallback(() => setAuthMode(user ? 'verify' : 'login'), [user]);
// guest is sent to the dialog for an account; an account that is already
// signed in has the basic tier, which no dialog here can change, so it is
// told where the grant comes from instead.
const promptPro = useCallback(() => {
if (!user) {
setAuthMode('login');
return;
}
setError(t('pro.locked'));
}, [user, t]);
// The banner's own button. The address already has one live link, so this
// replaces it — the API caps how often, and answers either way.
@@ -679,16 +691,17 @@ export function Workspace() {
}, []);
useEffect(() => {
// PRO, not merely signed in: those two listings are the API's own, and it
// answers an unverified account with a 403 (see requirePro there).
if (!pro) {
// The account's own two listings: the API answers a guest with a 401, and
// an unverified account is served like any other basic one (see
// requireMember there).
if (!member) {
setSaved([]);
setMyPhotos([]);
return;
}
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([]));
}, [pro]);
}, [member]);
// --- preview render -------------------------------------------------------
// A new photo has nothing to compare yet: the toggle goes off with it, so the
@@ -982,6 +995,14 @@ export function Workspace() {
// the caller that hands one over names it again on the way back.
setOpenPhotoId(null);
setLibPhotoId(null);
// A RAW is PRO: the file is parked whole and developed by the engine,
// which only that tier carries. A basic account is told, not silently
// served a JPEG it never asked for.
if (isRawName(file.name) && !pro) {
setBusy(null);
promptPro();
return;
}
try {
const bytes = await fileToBytes(file);
if (isRawName(file.name)) {
@@ -1003,7 +1024,7 @@ export function Workspace() {
setBusy(null);
}
},
[t, adoptPhoto]
[t, adoptPhoto, pro, promptPro]
);
useEffect(() => {
@@ -1374,12 +1395,12 @@ export function Workspace() {
);
const saveRecipe = useCallback(() => {
if (!pro) {
if (!member) {
promptPro();
return;
}
setNaming('recipe');
}, [pro, promptPro]);
}, [member, promptPro]);
// A recipe name is only free while this account has not spent it. Checked on
// the field rather than at the store: the list is already here, whole.
@@ -1406,7 +1427,7 @@ export function Workspace() {
// asked to sign in first, like every other write in the studio.
const favoriteRecipe = useCallback(
async (entry: Recipe) => {
if (!pro) {
if (!member) {
promptPro();
return;
}
@@ -1418,7 +1439,7 @@ export function Workspace() {
setError(t('err.generic', { msg: String(err) }));
}
},
[pro, promptPro, t]
[member, promptPro, t]
);
// The star reads the id the dragged chip carried back to its recipe.
@@ -1451,7 +1472,7 @@ export function Workspace() {
const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true };
setCreated((list) => [entry, ...list]);
applyRecipe(entry);
if (!pro) return;
if (!member) return;
try {
const stored = await api.createRecipe(entry.name, entry);
setSaved((list) => [stored.recipe, ...list]);
@@ -1459,7 +1480,7 @@ export function Workspace() {
setError(t('err.generic', { msg: String(err) }));
}
},
[pro, applyRecipe, t]
[member, applyRecipe, t]
);
// CREATE edits the photo live: each knob lands on the render as it is typed so
@@ -1474,7 +1495,7 @@ export function Workspace() {
// unverified account's, is never written to disk.
const exportRecipeFile = useCallback(
(draft: RecipeDraft) => {
if (!pro) {
if (!member) {
promptPro();
return;
}
@@ -1485,7 +1506,7 @@ export function Workspace() {
'application/xml'
);
},
[pro, promptPro]
[member, promptPro]
);
// IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved
@@ -1585,9 +1606,11 @@ export function Workspace() {
setExporting(true);
setError(null);
try {
const tier = TIERS[pro ? 'user' : 'guest'];
// The menu's answer, under the tier's ceiling. UNCHANGED is `null`.
const wanted = target === null ? null : Math.min(target, tier.maxExportDim ?? target);
const tier = pro ? TIERS.pro : member ? TIERS.member : TIERS.guest;
// The menu's answer, under the tier's ceiling. UNCHANGED is `null`, and
// the ceiling a basic account carries is the photo it is editing: the
// pixels past its own long edge are the model's, and they are PRO's.
const wanted = target === null ? null : Math.min(target, exportCap ?? target);
// How far the photo's own long edge must be stretched for the CROP to
// land on `wanted`: the render keeps only `editedLongest` of the photo.
const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest);
@@ -1673,7 +1696,7 @@ export function Workspace() {
// The upscale's own progress line goes with the export that wrote it.
setBusy(null);
}
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]);
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, exportCap, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, member, promptPro, proLookInUse, t, strip, photoSettings]);
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
// The upload is left clean (no burned strip) so the landing reel can draw its
@@ -1682,7 +1705,7 @@ export function Workspace() {
// `true` once the row and its base are stored, so CLEAR only takes the photo
// off the stage when the save it offered really landed.
const savePhotoToFolder = useCallback(async (photoName?: string): Promise<boolean> => {
if (!pro) {
if (!member) {
promptPro();
return false;
}
@@ -1710,11 +1733,10 @@ export function Workspace() {
setSavingPhoto(true);
setError(null);
try {
const src = TIERS.user.maxExportDim
? await resizedJpeg(source.bytes, TIERS.user.maxExportDim, 0.95)
: source.bytes;
// No tier ceiling to apply here: both saving tiers are uncapped, and the
// upload's own byte cap is api.ts's (see uploadBytes).
const out = await renderPhoto({
source: src,
source: source.bytes,
recipe,
frameId: cropEditing ? 'none' : frameId,
useGeotag,
@@ -1739,7 +1761,7 @@ export function Workspace() {
// these pixels — everything else is what reopening re-applies, and
// applying it to the render is what doubled the frame and the look.
const base = await renderPhoto({
source: src,
source: source.bytes,
recipe: BASE_RECIPE,
frameId: 'none',
assets: engine.assets,
@@ -1791,7 +1813,7 @@ export function Workspace() {
} finally {
setSavingPhoto(false);
}
}, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, strip, photoSettings, t]);
}, [member, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, strip, photoSettings, t]);
// CLEAR's "keep it" answers: the save lands and the photo goes, or the export
// hands a file over and then it goes. Neither forces the hand — a save that
@@ -1827,9 +1849,9 @@ export function Workspace() {
// Some menu entry will be larger than this photo (the crop already counted),
// so an upscale is one tap away: fetch the runtime and the model while the
// visitor is still reading the sizes, instead of after they have chosen one.
if (editedLongest < (TIERS[pro ? 'user' : 'guest'].maxExportDim ?? 4096)) preloadSuperRes();
if (editedLongest < (exportCap ?? 4096)) preloadSuperRes();
setChoosingExport(true);
}, [engine, source, pro, proLookInUse, promptPro, editedLongest]);
}, [engine, source, pro, exportCap, proLookInUse, promptPro, editedLongest]);
// The original file, handed back byte for byte — the untouched frame the
// camera took, never the render. A page cannot write to the camera roll, so
@@ -2618,16 +2640,6 @@ export function Workspace() {
},
});
// Shown where an unverified account would otherwise just see an empty list.
// The list is not empty because the account has nothing — it is shut until
// the address is proven, and saying so is the whole point of the chip.
const verifyChip = (): ChipDef => ({
key: 'verify-now',
label: t('pro.verifyChip'),
pro: true,
onClick: promptPro,
});
const framesRow = (): ChipDef[] => [
// NO FRAME is not a chip of its own: clicking the frame already on the
// photo takes it off again, so the row carries one entry per frame.
@@ -2743,7 +2755,6 @@ export function Workspace() {
// IMPORT is the chip under RECIPES in the tab's own column.
];
case 'favorited':
if (user && !pro) return [verifyChip()];
return saved.length === 0
? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }]
: saved.map((entry) => ({
@@ -2764,7 +2775,6 @@ export function Workspace() {
// the landing strip — the owner takes it off again with that switch, or
// the curator with the admin slot.
const rows: ChipDef[] = [];
if (user && !pro) return [verifyChip()];
if (myPhotos.length === 0) {
rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} });
} else {
@@ -2802,6 +2812,9 @@ export function Workspace() {
//
// Nothing here is a knob, so the tab is this row alone: the strip a chip
// opens is the last column's (see stripChips), the same as anywhere else.
// The whole tab is PRO — the four write to the frame as the phone's own
// edition does — so a basic account is offered the reason instead.
if (!pro) return [{ key: 'tools-pro', label: t('pro.toolsChip'), pro: true, onClick: promptPro }];
return [
{ key: 'auto', label: '✦ AUTO', onClick: () => void autoTune() },
// FX's tools are not looks: each is a strip of its own, opened from the
@@ -3200,21 +3213,24 @@ export function Workspace() {
{t('notice.trial')}
</div>
{/* A signed-in account whose address is unproven is served as a guest, so
the studio says so once, at the top, instead of letting each locked
chip explain it on its own. */}
{/* A signed-in account the operator has not activated has the basic tier,
so the studio says what that means once, at the top, instead of letting
each locked chip explain it on its own. An unproven address is the same
tier — with a button for the letter still standing in the way. */}
{user && !pro ? (
<div className="verify-bar" role="status">
<span>{t('pro.unverifiedBar', { email: user.email })}</span>
<button
type="button"
className="btn"
data-key="verify-resend"
disabled={resent}
onClick={() => void resendVerification()}
>
{resent ? t('pro.resent') : t('pro.resend')}
</button>
<span>{t('pro.basicBar', { email: user.email })}</span>
{user.verified ? null : (
<button
type="button"
className="btn"
data-key="verify-resend"
disabled={resent}
onClick={() => void resendVerification()}
>
{resent ? t('pro.resent') : t('pro.resend')}
</button>
)}
</div>
) : null}
@@ -3713,7 +3729,7 @@ export function Workspace() {
{choosingExport ? (
<ExportModal
longest={editedLongest}
maxDim={TIERS[pro ? 'user' : 'guest'].maxExportDim}
maxDim={exportCap}
onPick={(target) => {
setChoosingExport(false);
void exportPhoto(target);
+6 -1
View File
@@ -19,6 +19,9 @@ export interface User {
// Signed-in accounts only unlock the PRO tier once their address is proven;
// an unverified one is served exactly like a guest. Admins count as verified.
verified?: boolean;
// The PRO tier itself: the proof above, or a grant the operator ticked for
// this account in the admin users table.
pro?: boolean;
// A ready-to-use picture URL (`/api/users/<id>/avatar?v=<file>`), or null.
// The version segment is the file's own name, so a replacement is never
// served from cache.
@@ -126,6 +129,8 @@ export interface AdminUser {
// but restorable (a hard delete drops the row entirely).
blocked: boolean;
removed: boolean;
// The "Activated Pro" box: true hands this account the studio's PRO tier.
pro: boolean;
}
// One row of a grouped count on the traffic screen. The API sorts them, largest
@@ -313,7 +318,7 @@ export const api = {
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
// the final act and takes the account's photos and recipes with it.
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
+13 -6
View File
@@ -1,12 +1,18 @@
// One table for the guest/user split, so the policy can change without touching
// the UI. Guest = the landing + a capped, watermarked demo; user = full tools,
// full-resolution export, saved recipes.
// One table for the three tiers, so the policy can change without touching the
// UI. guest = the landing + a capped, watermarked demo; member = any signed-in
// account, which is the basic tier: recipes, the photo folder and a clean export
// of the photo it is editing; pro = the operator's Activated Pro box, which adds
// the PRO tools (HSL, TOOLS and GRADIENT MASK, RAW) and lets the export run past
// the photo's own pixels.
//
// `maxExportDim` here is the tier's own ceiling. The basic tier's ceiling is the
// photo being edited, which only the caller knows — see `exportCap` in App.
export type TierId = 'guest' | 'user';
export type TierId = 'guest' | 'member' | 'pro';
export interface Tier {
id: TierId;
/** Longest edge of an exported photo, or null for the original size. */
/** Longest edge of an exported photo, or null for the caller's own ceiling. */
maxExportDim: number | null;
/** Text burned into every export, or null for a clean file. */
watermark: string | null;
@@ -15,5 +21,6 @@ export interface Tier {
export const TIERS: Record<TierId, Tier> = {
guest: { id: 'guest', maxExportDim: 2048, watermark: 'RecipesCam demo', batchExport: false },
user: { id: 'user', maxExportDim: null, watermark: null, batchExport: true },
member: { id: 'member', maxExportDim: null, watermark: null, batchExport: false },
pro: { id: 'pro', maxExportDim: null, watermark: null, batchExport: true },
};
+10 -6
View File
@@ -137,7 +137,7 @@ export const en: Dict = {
'auth.loggedInAs': 'Signed in as {email}',
'auth.verifyTitle': 'Verify your email',
'auth.verifyBody': 'We sent a six-digit code to {email}. Type it below — or open the link in the mail — to unlock the PRO features.',
'auth.verifyProHint': 'Until then the account works exactly like a guest: exports stay 2048px with a watermark, and nothing can be saved.',
'auth.verifyProHint': 'Until then the account stays on the basic tier: recipes, saved photos (12 max), and an export inside the long edge of the photo being edited. PRO features (HSL, TOOLS, RAW, exports past it) need the account to be activated.',
'auth.verifySent': 'A new code has been sent.',
'auth.codePlaceholder': 'The six digits from the mail',
'auth.verifyBtn': 'VERIFY',
@@ -149,11 +149,14 @@ export const en: Dict = {
// whom, and until when.
'notice.trial': 'Some features are on trial for registered users until 1 December; after that they open only in the Android app.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'VERIFY EMAIL TO USE PRO',
// The PRO gate. A signed-in account the operator has not activated has the
// basic tier, so the studio needs one place that says what that means (the
// bar), one reason per locked tab (the chips), and one answer for a click on
// either of them.
'pro.hslChip': 'HSL IS A PRO FEATURE',
'pro.unverifiedBar': 'Account {email} is not verified — it works like a guest.',
'pro.toolsChip': 'TOOLS & GRADIENT MASK ARE PRO FEATURES',
'pro.basicBar': 'Account {email} is on the basic tier — PRO features need an account the operator has activated.',
'pro.locked': 'PRO feature — this account has not been activated. Ask the operator to tick "Activated Pro".',
'pro.resend': 'RESEND VERIFICATION EMAIL',
'pro.resent': 'SENT',
@@ -210,7 +213,7 @@ export const en: Dict = {
'export.4k': 'EXPORT 4K (4096px)',
'export.customLabel': 'Longest edge (px)',
'export.custom': 'EXPORT',
'export.cap': 'Guest exports are capped at {px}px.',
'export.cap': 'This account can export up to {px}px.',
'photo.uploading': 'UPLOADING…',
@@ -325,6 +328,7 @@ export const en: Dict = {
'adm.userName': 'Name',
'adm.userEmail': 'Email',
'adm.userRole': 'Role',
'adm.userPro': 'Activated Pro',
'adm.userPhotos': 'Photos',
'adm.userJoined': 'Joined',
'adm.roleAdmin': 'Admin',
+10 -6
View File
@@ -150,7 +150,7 @@ export const vi = {
'auth.loggedInAs': 'Đã đăng nhập: {email}',
'auth.verifyTitle': 'Xác thực email',
'auth.verifyBody': 'Đã gửi mã xác thực 6 số tới {email}. Nhập mã vào ô dưới đây — hoặc mở liên kết trong email — để dùng được các tính năng PRO.',
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản dùng y như khách: ảnh xuất ra tối đa 2048px kèm watermark và không lưu được gì.',
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản vẫn ở bản cơ bản: lưu công thức, lưu ảnh (tối đa 12) và xuất ảnh trong giới hạn cạnh dài của ảnh đang chỉnh sửa. Các tính năng PRO (HSL, TOOLS, RAW, xuất lớn hơn) cần tài khoản được kích hoạt.',
'auth.verifySent': 'Đã gửi lại mã xác thực.',
'auth.codePlaceholder': 'Mã 6 số trong email',
'auth.verifyBtn': 'XÁC THỰC',
@@ -162,11 +162,14 @@ export const vi = {
// whom, and until when.
'notice.trial': 'Một vài tính năng sẽ được sử dụng thử với người đã đăng ký đến ngày 1/12, sau đó chỉ mở các tính năng này trên ứng dụng android',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO',
// The PRO gate. A signed-in account the operator has not activated has the
// basic tier, so the studio needs one place that says what that means (the
// bar), one reason per locked tab (the chips), and one answer for a click on
// either of them.
'pro.hslChip': 'HSL LÀ TÍNH NĂNG PRO',
'pro.unverifiedBar': 'Tài khoản {email} chưa xác thực email — đang dùng như khách.',
'pro.toolsChip': 'TOOLS & GRADIENT MASK LÀ TÍNH NĂNG PRO',
'pro.basicBar': 'Tài khoản {email} đang ở bản cơ bản — tính năng PRO cần tài khoản được kích hoạt.',
'pro.locked': 'Tính năng PRO — tài khoản chưa được kích hoạt. Nhờ quản trị viên tích "Kích hoạt PRO".',
'pro.resend': 'GỬI LẠI EMAIL XÁC THỰC',
'pro.resent': 'ĐÃ GỬI',
@@ -223,7 +226,7 @@ export const vi = {
'export.4k': 'XUẤT 4K (4096px)',
'export.customLabel': 'Cạnh dài nhất (px)',
'export.custom': 'XUẤT',
'export.cap': 'Bản khách giới hạn ở {px}px.',
'export.cap': 'Tài khoản này xuất tối đa {px}px.',
'photo.uploading': 'ĐANG TẢI…',
@@ -334,6 +337,7 @@ export const vi = {
'adm.userName': 'Tên',
'adm.userEmail': 'Email',
'adm.userRole': 'Quyền',
'adm.userPro': 'Kích hoạt PRO',
'adm.userPhotos': 'Số ảnh',
'adm.userJoined': 'Ngày tạo',
'adm.roleAdmin': 'Quản trị',
+27
View File
@@ -1261,7 +1261,34 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.adm-side-item:hover { background: var(--bg-elev); }
.adm-side-item.on { background: var(--accent); color: var(--accent-fg); font-weight: 600; }
.adm-pane { flex: 1 1 auto; min-width: 0; padding: 14px 16px; }
/* The users table: a frame around every cell, and a head that reads as a head —
the heading face, smaller and dimmer than the rows under it. The accounts are
what the eye should land on, so the header steps back. */
.adm-table-wrap { overflow-x: auto; }
.adm-table { border-collapse: collapse; width: 100%; }
.adm-table th,
.adm-table td {
border: 1px solid var(--border);
padding: 6px 9px;
text-align: left;
vertical-align: middle;
}
.adm-table th {
background: var(--bg-sunken);
color: var(--text-dim);
font-family: var(--font-heading);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.adm-table tbody tr:hover { background: var(--bg-sunken); }
/* The PRO box: one narrow centred column, and a box big enough to aim at. */
.adm-table th.adm-pro,
.adm-table td.adm-pro { width: 1%; text-align: center; white-space: nowrap; }
.adm-table td.adm-pro input { display: block; margin: 0 auto; cursor: pointer; }
.adm-table td.adm-pro input:disabled { cursor: default; }
/* Bulk selection: the three controls above the users table, and the checkbox
column at its head (narrow so the picture column keeps the width). */
.adm-bulk { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
+17 -9
View File
@@ -24,9 +24,14 @@ export function ExportModal({
const { t } = useI18n();
const [custom, setCustom] = useState(String(Math.min(CUSTOM_MAX, Math.max(1024, longest * 2))));
// `maxDim` is whatever the account's tier allows: none for PRO, the photo's
// own long edge for a basic account, 2048 for a guest. The field's own `max`
// says so, so a number past it is refused where it is typed rather than
// silently clamped on the way out.
const customMax = Math.min(CUSTOM_MAX, maxDim ?? CUSTOM_MAX);
const pick = (n: number | null) => onPick(n !== null && maxDim !== null ? Math.min(n, maxDim) : n);
const customValue = Number(custom);
const customOk = Number.isFinite(customValue) && customValue >= 1 && customValue <= CUSTOM_MAX;
const customOk = Number.isFinite(customValue) && customValue >= 1 && customValue <= customMax;
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
@@ -38,12 +43,14 @@ export function ExportModal({
<button type="button" className="btn" data-key="export-same" onClick={() => pick(null)}>
{t('export.same')}
</button>
<button type="button" className="btn" data-key="export-2k" onClick={() => pick(2048)}>
{t('export.2k')}
</button>
{/* A capped account cannot be handed a size its tier refuses, so the two
sizes over the ceiling are not offered at all. */}
{maxDim === null || maxDim > 2048 ? (
{(maxDim === null || maxDim >= 2048) && (
<button type="button" className="btn" data-key="export-2k" onClick={() => pick(2048)}>
{t('export.2k')}
</button>
)}
{/* A size the ceiling refuses is not offered at all — picking it would
only hand back the cap. The note below names that cap. */}
{(maxDim === null || maxDim >= 4096) && (
<>
<button type="button" className="btn" data-key="export-4k" onClick={() => pick(4096)}>
{t('export.4k')}
@@ -57,7 +64,7 @@ export function ExportModal({
className="field"
type="number"
min={1}
max={CUSTOM_MAX}
max={customMax}
value={custom}
onChange={(e) => setCustom(e.target.value)}
/>
@@ -72,7 +79,8 @@ export function ExportModal({
</button>
</div>
</>
) : (
)}
{maxDim !== null && (
<p className="hint" data-key="export-cap">
{t('export.cap', { px: maxDim })}
</p>