mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-11 22:32:26 +00:00
fix: settle which plan an org seat resolves to, and confirm a seat's address (#4139)
* fix: settle which default plan an org seat resolves to, and what counts as paid Default subscription resolvers ran in registration order, which is boot order: whichever layer registered first answered first. A seat is provisioned by a team and its address is never confirmed, so the seat resolver now states a priority and wins over anything a deployment infers from an address. The seat cap asked whether the owner's plan was outside the free set, which a default plan can be without anyone having bought it. Metering now reports whether an explicit subscription answered, and the cap rests on that. * fix: the review's findings on plan resolution `fromDefault` was read off which resolver answered, not off which policy applied. An explicit id naming a policy nobody registered falls through to the default one, and that read as bought. It follows the applied policy now. A deployment with unlimited metering has no explicit resolver, so every owner on it became unpaid and its teams dropped from the paid seat cap to the free one. Unlimited answers for what a paid plan would, so it counts as one. Also: the new constants had been placed between `FREE_SUBSCRIPTION_IDS` and its docblock, the register docstring still described first-wins, and prettier had reflowed an untouched licence header. * feat: a seat confirms the address its team typed in An address on a seat is the team's claim about someone else's inbox, and nothing asked the inbox. It is now demanded and sent a code, so the address counts for nothing until the seat hands it back. Resolvers that read a domain are the reason this matters. No address is still fine and demands nothing: the team is the trust anchor for the account, not for a mailbox it does not hold. `/confirm-email` allows an unconfirmed actor, so a seat can sign in with its temporary credential and clear the demand; nothing locks it out. * fix: clear the confirmation demand with the address it names `clearUnconfirmedEmail` releases a seat's address so the inbox's owner can sign up with it. The demand to confirm that address has to go at the same write: the gate reads `requires_email_confirmation && !email_confirmed`, and once the address is gone there is nothing left to send a code to. * fix: keep a seat's address correctable, and only ask what can be answered Review of the confirmation demand turned up two ways a seat could be left holding one it had no way to meet: - With no mail transport configured, the code is never sent and a resend cannot reach the seat either, so the demand stood with nothing able to lift it. The seat is now gated only when a code can actually travel. - An address the owner mistyped gated the seat on an inbox nobody could read, and the gate itself closes `/user-protected/change-email`, the route the seat would have fixed it by. `updateSeatEmail` lets the owner who typed the address retarget it, and refuses once it is confirmed, when it belongs to whoever proved it. Also resolves the owner's plan sequentially, so the second read takes the cache the first wrote and the pair cannot come from two resolutions, and restores the licence header a jsdoc reflow had rewrapped. * fix: let the confirm-email window open before the profile has loaded Booted from a stored token, the window renders before `window.user` is populated, and reading `.email` off null threw inside the window itself -- so the one screen that could have asked for the code never drew, leaving a blank page. Reproducible without a team: sign up, then load the GUI again before confirming. The address is now encoded rather than interpolated raw, and both forms of the line are i18n keys; it was hardcoded English before.
This commit is contained in:
1 parent
7de8360a6e
commit
86cc3490f7
10 files changed
+531
-91
No files matched your search
@@ -338,6 +338,29 @@ export class TeamController extends PuterController {
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
// The gate closes the seat's own route to fix it, so the owner must.
|
||||
@Post('/:uid/members/:username/email', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateSeatEmail(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.updateSeatEmail(
|
||||
uid,
|
||||
userId,
|
||||
target,
|
||||
(req.body as { email?: unknown } | undefined)?.email ?? null,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// Same budget as a password reset: both are a step toward the account.
|
||||
@Post('/:uid/members/:username/2fa-reset', {
|
||||
subdomain: 'api',
|
||||
|
||||
@@ -16,6 +16,8 @@ import { bucketTag } from '../../stores/metering/MeteringBufferStore.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
UNLIMITED_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
GLOBAL_APP_KEY,
|
||||
METRICS_PREFIX,
|
||||
@@ -45,9 +47,9 @@ const currentMonthString = (): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* How many `incr` calls one buffered write of `types` costs against the
|
||||
* actor's own usage item(s): one totals write plus one per distinct detail
|
||||
* shard the types hash to.
|
||||
* How many `incr` calls one buffered write of `types` costs against the actor's
|
||||
* own usage item(s): one totals write plus one per distinct detail shard the
|
||||
* types hash to.
|
||||
*/
|
||||
const usageWriteCalls = (types: string[]): number =>
|
||||
1 + new Set(types.map((t) => detailShardOf(t))).size;
|
||||
@@ -179,6 +181,124 @@ describe('MeteringService', () => {
|
||||
expect(policy.id).toBe(DEFAULT_TEMP_SUBSCRIPTION);
|
||||
});
|
||||
|
||||
it('runs the default resolvers in the order they asked for, not the order they arrived', async () => {
|
||||
const seatLike = {
|
||||
id: 'seat-like',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 1024,
|
||||
};
|
||||
const domainLike = {
|
||||
id: 'domain-like',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 2048,
|
||||
};
|
||||
target.registerPolicy(seatLike);
|
||||
target.registerPolicy(domainLike);
|
||||
|
||||
// Registered first, as the extension layer is: it still loses.
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => 'domain-like',
|
||||
);
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => 'seat-like',
|
||||
{
|
||||
priority: 10,
|
||||
},
|
||||
);
|
||||
|
||||
const policy = await target.getActorSubscription(actor);
|
||||
expect(policy.id).toBe('seat-like');
|
||||
});
|
||||
|
||||
it('calls a plan nobody bought a default, however generous it is', async () => {
|
||||
const generous = {
|
||||
id: 'generous-default',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
};
|
||||
target.registerPolicy(generous);
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => 'generous-default',
|
||||
);
|
||||
|
||||
const defaulted = makeActor();
|
||||
expect(await target.getActorSubscription(defaulted)).toMatchObject({
|
||||
id: 'generous-default',
|
||||
});
|
||||
expect(await target.actorHasPaidSubscription(defaulted)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls the same plan paid once something subscribed the actor to it', async () => {
|
||||
const generous = {
|
||||
id: 'bought-plan',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
};
|
||||
target.registerPolicy(generous);
|
||||
target.registerSubscriptionResolver(async () => 'bought-plan');
|
||||
|
||||
const subscribed = makeActor();
|
||||
expect(await target.getActorSubscription(subscribed)).toMatchObject(
|
||||
{
|
||||
id: 'bought-plan',
|
||||
},
|
||||
);
|
||||
expect(await target.actorHasPaidSubscription(subscribed)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('counts unlimited as paid, so a deployment that turned it on keeps its caps', async () => {
|
||||
const cfg = (
|
||||
target as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
const saved = cfg.unlimitedMetering;
|
||||
cfg.unlimitedMetering = true;
|
||||
try {
|
||||
const anyone = makeActor();
|
||||
expect((await target.getActorSubscription(anyone)).id).toBe(
|
||||
UNLIMITED_SUBSCRIPTION,
|
||||
);
|
||||
expect(await target.actorHasPaidSubscription(anyone)).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
cfg.unlimitedMetering = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it('calls it a default when the explicit id names no registered policy', async () => {
|
||||
const fallback = {
|
||||
id: 'registered-default',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
};
|
||||
target.registerPolicy(fallback);
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => 'registered-default',
|
||||
);
|
||||
// As an extension that failed to load would name.
|
||||
target.registerSubscriptionResolver(async () => 'ghost-plan');
|
||||
|
||||
const actorOnGhost = makeActor();
|
||||
expect((await target.getActorSubscription(actorOnGhost)).id).toBe(
|
||||
'registered-default',
|
||||
);
|
||||
expect(await target.actorHasPaidSubscription(actorOnGhost)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls a free plan unpaid even when it was subscribed to', async () => {
|
||||
target.registerSubscriptionResolver(
|
||||
async () => ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
);
|
||||
const seat = makeActor();
|
||||
expect(await target.actorHasPaidSubscription(seat)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the first non-empty subscription resolver', async () => {
|
||||
const customPolicy = {
|
||||
id: 'custom-paid',
|
||||
@@ -1494,8 +1614,7 @@ describe('MeteringService', () => {
|
||||
expect(result.total).toBe(500);
|
||||
const adj = (result as Record<string, unknown>)
|
||||
.manual_adjustment as
|
||||
| { cost: number; units: number; count: number }
|
||||
| undefined;
|
||||
{ cost: number; units: number; count: number } | undefined;
|
||||
expect(adj).toMatchObject({ cost: 500, units: 500, count: 1 });
|
||||
});
|
||||
|
||||
@@ -1650,11 +1769,10 @@ describe('MeteringService', () => {
|
||||
expect(typeof value).toBe('object');
|
||||
}
|
||||
|
||||
const detailed =
|
||||
await target.getActorCurrentMonthAppUsageDetails(
|
||||
appActor,
|
||||
'count-app',
|
||||
);
|
||||
const detailed = await target.getActorCurrentMonthAppUsageDetails(
|
||||
appActor,
|
||||
'count-app',
|
||||
);
|
||||
expect(detailed.count).toBeUndefined();
|
||||
|
||||
// appTotals reads the same item separately and still has count.
|
||||
@@ -2794,9 +2912,8 @@ describe('MeteringService', () => {
|
||||
ctor.EXACT_READ_MIN_INTERVAL_MS = 60_000;
|
||||
try {
|
||||
const bufActor: Actor = { user: makeUser() };
|
||||
const allowance = (
|
||||
await target.getActorSubscription(bufActor)
|
||||
).monthUsageAllowance;
|
||||
const allowance = (await target.getActorSubscription(bufActor))
|
||||
.monthUsageAllowance;
|
||||
|
||||
// Already near the allowance — every further call re-triggers
|
||||
// the near-allowance check, and the first one already read
|
||||
@@ -3711,8 +3828,7 @@ describe('MeteringService', () => {
|
||||
const admitted = usages.filter((u) => usage[u.usageType]);
|
||||
expect(admitted.length).toBeLessThanOrEqual(5);
|
||||
const other = usage[OTHER_USAGE_TYPE] as
|
||||
| { cost: number }
|
||||
| undefined;
|
||||
{ cost: number } | undefined;
|
||||
expect(other).toBeDefined();
|
||||
|
||||
// Nothing was lost, only regrouped: every admitted type's
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PuterService } from '../types';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
DEFAULT_RESOLVER_PRIORITY,
|
||||
DETAIL_PATH_COUNTER,
|
||||
GLOBAL_APP_KEY,
|
||||
METRICS_PREFIX,
|
||||
@@ -255,7 +256,10 @@ export class MeteringService extends PuterService {
|
||||
private usageBufferTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private extraPolicies: SubscriptionPolicy[] = [];
|
||||
private subscriptionResolvers: SubscriptionResolver[] = [];
|
||||
private defaultSubscriptionResolvers: SubscriptionResolver[] = [];
|
||||
private defaultSubscriptionResolvers: Array<{
|
||||
fn: SubscriptionResolver;
|
||||
priority: number;
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* Fire-and-forget aux writes (`handleAuxPromise`) still in flight. Nothing
|
||||
@@ -267,7 +271,13 @@ export class MeteringService extends PuterService {
|
||||
/** Uuid → resolved policy + expiry; `lookupFailed` marks a fallback. */
|
||||
private subscriptionCache = new Map<
|
||||
string,
|
||||
{ policy: SubscriptionPolicy; expiresAt: number; lookupFailed: boolean }
|
||||
{
|
||||
policy: SubscriptionPolicy;
|
||||
expiresAt: number;
|
||||
lookupFailed: boolean;
|
||||
/** No explicit subscription answered, so this is a default. */
|
||||
fromDefault: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
/** Uuid → the last budget state announced, so a retry loop emits once. */
|
||||
@@ -469,10 +479,17 @@ export class MeteringService extends PuterService {
|
||||
|
||||
/**
|
||||
* Register a resolver that maps an actor to a _default_ subscription id,
|
||||
* used when no explicit subscription is set. First non-empty wins.
|
||||
* used when none is set. Lowest `priority` is asked first.
|
||||
*/
|
||||
registerDefaultSubscriptionResolver(fn: SubscriptionResolver): void {
|
||||
this.defaultSubscriptionResolvers.push(fn);
|
||||
registerDefaultSubscriptionResolver(
|
||||
fn: SubscriptionResolver,
|
||||
{ priority = DEFAULT_RESOLVER_PRIORITY }: { priority?: number } = {},
|
||||
): void {
|
||||
this.defaultSubscriptionResolvers.push({ fn, priority });
|
||||
// Lowest first, so the winner is a stated number, not a boot order.
|
||||
this.defaultSubscriptionResolvers.sort(
|
||||
(a, b) => a.priority - b.priority,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Public API: increment usage ----------------------------------
|
||||
@@ -2043,6 +2060,15 @@ export class MeteringService extends PuterService {
|
||||
return (await this.#actorSubscriptionWithStatus(actor)).policy;
|
||||
}
|
||||
|
||||
/** On a plan somebody subscribed them to, not one a resolver inferred. */
|
||||
async actorHasPaidSubscription(actor: Actor): Promise<boolean> {
|
||||
const { policy, fromDefault } =
|
||||
await this.#actorSubscriptionWithStatus(actor);
|
||||
// A deployment that turned unlimited on means it.
|
||||
if (policy.id === UNLIMITED_SUBSCRIPTION) return true;
|
||||
return !fromDefault && !isFreeSubscription(policy.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* `provisionalUntil` is set when the answer may not be this actor's plan
|
||||
* (failed lookup, or an actor missing its email or numeric id). Clean
|
||||
@@ -2051,6 +2077,7 @@ export class MeteringService extends PuterService {
|
||||
async #actorSubscriptionWithStatus(actor: Actor): Promise<{
|
||||
policy: SubscriptionPolicy;
|
||||
provisionalUntil?: number;
|
||||
fromDefault: boolean;
|
||||
}> {
|
||||
if (!actor.user?.uuid)
|
||||
throw new HttpError(403, 'Actor must be a user to get policy', {
|
||||
@@ -2063,6 +2090,7 @@ export class MeteringService extends PuterService {
|
||||
if (cached && cached.expiresAt > now)
|
||||
return {
|
||||
policy: cached.policy,
|
||||
fromDefault: cached.fromDefault,
|
||||
provisionalUntil: cached.lookupFailed
|
||||
? cached.expiresAt
|
||||
: undefined,
|
||||
@@ -2078,6 +2106,7 @@ export class MeteringService extends PuterService {
|
||||
) {
|
||||
return {
|
||||
policy: resolved.policy,
|
||||
fromDefault: resolved.fromDefault,
|
||||
provisionalUntil:
|
||||
now + MeteringService.SUBSCRIPTION_FALLBACK_CACHE_MS,
|
||||
};
|
||||
@@ -2091,7 +2120,10 @@ export class MeteringService extends PuterService {
|
||||
stillFresh.expiresAt > now &&
|
||||
!stillFresh.lookupFailed
|
||||
) {
|
||||
return { policy: stillFresh.policy };
|
||||
return {
|
||||
policy: stillFresh.policy,
|
||||
fromDefault: stillFresh.fromDefault,
|
||||
};
|
||||
}
|
||||
|
||||
// Map preserves insertion order; FIFO-evict so a flood of one-shot
|
||||
@@ -2111,18 +2143,22 @@ export class MeteringService extends PuterService {
|
||||
this.subscriptionCache.set(uuid, {
|
||||
policy: resolved.policy,
|
||||
lookupFailed: resolved.lookupFailed,
|
||||
fromDefault: resolved.fromDefault,
|
||||
expiresAt,
|
||||
});
|
||||
return {
|
||||
policy: resolved.policy,
|
||||
fromDefault: resolved.fromDefault,
|
||||
provisionalUntil: resolved.lookupFailed ? expiresAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** `lookupFailed`: a resolver or the allowance read threw, so it fell back. */
|
||||
async #resolveActorSubscription(
|
||||
actor: Actor,
|
||||
): Promise<{ policy: SubscriptionPolicy; lookupFailed: boolean }> {
|
||||
async #resolveActorSubscription(actor: Actor): Promise<{
|
||||
policy: SubscriptionPolicy;
|
||||
lookupFailed: boolean;
|
||||
fromDefault: boolean;
|
||||
}> {
|
||||
const fallbackDefault = this.config.unlimitedMetering
|
||||
? UNLIMITED_SUBSCRIPTION
|
||||
: actor.user?.email
|
||||
@@ -2130,7 +2166,7 @@ export class MeteringService extends PuterService {
|
||||
: DEFAULT_TEMP_SUBSCRIPTION;
|
||||
|
||||
const defaults = await this.firstResolver(
|
||||
this.defaultSubscriptionResolvers,
|
||||
this.defaultSubscriptionResolvers.map((entry) => entry.fn),
|
||||
actor,
|
||||
);
|
||||
const resolvedDefault = defaults.id || fallbackDefault;
|
||||
@@ -2154,10 +2190,13 @@ export class MeteringService extends PuterService {
|
||||
// the built-in free policy keeps callers holding a real policy: the
|
||||
// alternative is `undefined` reaching every reader of `.id` and
|
||||
// `.monthUsageAllowance` as a 500 rather than a downgrade.
|
||||
const explicit = availablePolicies.find((p) => p.id === resolvedUser);
|
||||
const policy =
|
||||
availablePolicies.find((p) => p.id === resolvedUser) ??
|
||||
explicit ??
|
||||
availablePolicies.find((p) => p.id === resolvedDefault) ??
|
||||
availablePolicies.find((p) => p.id === fallbackDefault);
|
||||
// Of the policy that applied, not the id that was asked for.
|
||||
const fromDefault = !user.id || !explicit;
|
||||
if (policy) {
|
||||
const withAllowance = await this.#withMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
@@ -2166,6 +2205,7 @@ export class MeteringService extends PuterService {
|
||||
return {
|
||||
policy: withAllowance.policy,
|
||||
lookupFailed: resolverFailed || withAllowance.failed,
|
||||
fromDefault,
|
||||
};
|
||||
}
|
||||
console.warn(
|
||||
@@ -2176,6 +2216,7 @@ export class MeteringService extends PuterService {
|
||||
return {
|
||||
policy: REGISTERED_USER_FREE as SubscriptionPolicy,
|
||||
lookupFailed: resolverFailed,
|
||||
fromDefault,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ export const FREE_SUBSCRIPTION_IDS: ReadonlySet<string> = new Set([
|
||||
export const isFreeSubscription = (id: string): boolean =>
|
||||
FREE_SUBSCRIPTION_IDS.has(id);
|
||||
|
||||
/** Where a default-subscription resolver sits; lower wins. */
|
||||
export const ORG_SEAT_RESOLVER_PRIORITY = 10;
|
||||
export const DEFAULT_RESOLVER_PRIORITY = 100;
|
||||
|
||||
/**
|
||||
* A plan's entry in a per-plan map. An unlisted free plan takes the default
|
||||
* free plan's entry; `undefined` means the caller's paid default applies.
|
||||
|
||||
@@ -19,9 +19,18 @@
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import { isFreeSubscription } from '../metering/consts.ts';
|
||||
|
||||
describe('team and seat caps', () => {
|
||||
let server: PuterServer;
|
||||
@@ -91,9 +100,7 @@ describe('team and seat caps', () => {
|
||||
Array.from({ length: 5 }, () => makeTeam(owner.id)),
|
||||
);
|
||||
|
||||
expect(
|
||||
results.filter((r) => r.status === 'fulfilled'),
|
||||
).toHaveLength(1);
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses on the cap before complaining about the handle', async () => {
|
||||
@@ -325,10 +332,11 @@ describe('team and seat caps', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// The cap asks whether the owner is on a plan somebody bought.
|
||||
const onPlan = (id: string) =>
|
||||
vi
|
||||
.spyOn(server.services.metering, 'getActorSubscription')
|
||||
.mockResolvedValue({ id } as never);
|
||||
.spyOn(server.services.metering, 'actorHasPaidSubscription')
|
||||
.mockResolvedValue(!isFreeSubscription(id));
|
||||
|
||||
const fill = async (teamUid: string, ownerId: number, n: number) => {
|
||||
for (let i = 0; i < n; i++) await provision(teamUid, ownerId);
|
||||
@@ -364,7 +372,7 @@ describe('team and seat caps', () => {
|
||||
provision(team.uid, owner.id),
|
||||
).rejects.toMatchObject({ legacyCode: 'seat_limit_reached' });
|
||||
|
||||
plan.mockResolvedValue({ id: 'basic' } as never);
|
||||
plan.mockResolvedValue(true);
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).resolves.toBeTruthy();
|
||||
@@ -432,12 +440,12 @@ describe('team and seat caps', () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
'actorHasPaidSubscription',
|
||||
).mockImplementation(async (actor: never) => {
|
||||
seen.push(
|
||||
(actor as { user: Record<string, unknown> }).user,
|
||||
);
|
||||
return { id: 'user_free' } as never;
|
||||
return false;
|
||||
});
|
||||
await provision(team.uid, owner.id);
|
||||
expect(seen[0]).toMatchObject({
|
||||
|
||||
@@ -127,7 +127,9 @@ describe('TeamService', () => {
|
||||
});
|
||||
|
||||
// The check exists precisely because the schema cannot refuse this.
|
||||
await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(false);
|
||||
await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// -- authority ----------------------------------------------------
|
||||
@@ -171,7 +173,7 @@ describe('TeamService', () => {
|
||||
expect(row.suspended_reason).toBe('disabled_by_team');
|
||||
});
|
||||
|
||||
it('drops the disabled account\'s sessions', async () => {
|
||||
it("drops the disabled account's sessions", async () => {
|
||||
const { team, member } = await makeTeam();
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `sessions` (`uuid`, `user_id`) VALUES (?, ?)',
|
||||
@@ -206,7 +208,7 @@ describe('TeamService', () => {
|
||||
expect(row.suspended_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the disabled account\'s files alone', async () => {
|
||||
it("leaves the disabled account's files alone", async () => {
|
||||
const { team, member } = await makeTeam();
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) VALUES (?, ?, ?, ?)',
|
||||
@@ -278,7 +280,6 @@ describe('TeamService', () => {
|
||||
expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
// -- provisioning -------------------------------------------------
|
||||
|
||||
it('creates an account the team owns, pending a password change', async () => {
|
||||
@@ -609,7 +610,7 @@ describe('TeamService', () => {
|
||||
expect(created.temporaryPassword).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('never demands confirmation, with or without an address', async () => {
|
||||
it('demands confirmation of an address, and only when one is given', async () => {
|
||||
// The gate is `requires_email_confirmation && !email_confirmed`.
|
||||
const { team } = await makeTeam();
|
||||
const bare = `bare_${Math.random().toString(36).slice(2, 9)}`;
|
||||
@@ -623,12 +624,71 @@ describe('TeamService', () => {
|
||||
email: `${withEmail}@test.local`,
|
||||
});
|
||||
|
||||
for (const id of [a.userId, b.userId]) {
|
||||
const row = await server.stores.user.getByProperty('id', id, {
|
||||
const rowOf = (id: number) =>
|
||||
server.stores.user.getByProperty('id', id, { force: true });
|
||||
|
||||
// No address, nothing to confirm: the team vouches for the account.
|
||||
const bareRow = await rowOf(a.userId);
|
||||
expect(Boolean(bareRow?.requires_email_confirmation)).toBe(false);
|
||||
|
||||
// An address the team typed in is a claim about someone else's inbox.
|
||||
const emailRow = await rowOf(b.userId);
|
||||
expect(Boolean(emailRow?.requires_email_confirmation)).toBe(true);
|
||||
expect(Boolean(emailRow?.email_confirmed)).toBe(false);
|
||||
// And a code it can actually be confirmed with.
|
||||
expect(emailRow?.email_confirm_code).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lets the seat clear the demand with the code it was sent', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const username = `conf_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
|
||||
const rowOf = () =>
|
||||
server.stores.user.getByProperty('id', created.userId, {
|
||||
force: true,
|
||||
});
|
||||
expect(Boolean(row?.requires_email_confirmation)).toBe(false);
|
||||
}
|
||||
const before = await rowOf();
|
||||
expect(Boolean(before?.requires_email_confirmation)).toBe(true);
|
||||
expect(Boolean(before?.email_confirmed)).toBe(false);
|
||||
|
||||
// What `/confirm-email` does once the seat hands back the code.
|
||||
await server.stores.user.update(created.userId, {
|
||||
email_confirmed: true,
|
||||
email_confirm_code: null,
|
||||
});
|
||||
|
||||
// The gate is `requires_email_confirmation && !email_confirmed`, so
|
||||
// confirming clears it without the demand having to be withdrawn.
|
||||
const after = await rowOf();
|
||||
expect(Boolean(after?.requires_email_confirmation)).toBe(true);
|
||||
expect(Boolean(after?.email_confirmed)).toBe(true);
|
||||
});
|
||||
|
||||
it('stops demanding confirmation when the address is released', async () => {
|
||||
// A demand left behind outlives the address it names.
|
||||
const { team } = await makeTeam();
|
||||
const username = `rel_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
|
||||
expect(await service.releaseUnconfirmedSeatEmail(created.userId)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const row = await server.stores.user.getByProperty(
|
||||
'id',
|
||||
created.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(row?.email).toBeNull();
|
||||
// The gate is `requires_email_confirmation && !email_confirmed`.
|
||||
expect(Boolean(row?.requires_email_confirmation)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an address when one is given', async () => {
|
||||
@@ -754,14 +814,20 @@ describe('TeamService', () => {
|
||||
await server.stores.user.invalidateById(owner.id);
|
||||
await service.updateTeam(team.uid, owner.id, { require2fa: true });
|
||||
expect(
|
||||
Number((await server.stores.team.getOrgSeat(member.id))?.require_2fa),
|
||||
Number(
|
||||
(await server.stores.team.getOrgSeat(member.id))
|
||||
?.require_2fa,
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
// The seat row is cached, so turning it off has to bust it or the
|
||||
// member stays locked out until the TTL lapses.
|
||||
await service.updateTeam(team.uid, owner.id, { require2fa: false });
|
||||
expect(
|
||||
Number((await server.stores.team.getOrgSeat(member.id))?.require_2fa),
|
||||
Number(
|
||||
(await server.stores.team.getOrgSeat(member.id))
|
||||
?.require_2fa,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1009,9 +1075,9 @@ describe('TeamService', () => {
|
||||
'disable',
|
||||
'provision',
|
||||
]);
|
||||
expect(
|
||||
forMember.every((e) => e.actor_username === ownerUsername),
|
||||
).toBe(true);
|
||||
expect(forMember.every((e) => e.actor_username === ownerUsername)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a member only their own entries', async () => {
|
||||
@@ -1027,7 +1093,10 @@ describe('TeamService', () => {
|
||||
email: `${b}@test.local`,
|
||||
});
|
||||
|
||||
const { items: own } = await service.listOwnAudit(team.uid, first.userId);
|
||||
const { items: own } = await service.listOwnAudit(
|
||||
team.uid,
|
||||
first.userId,
|
||||
);
|
||||
expect(own).toHaveLength(1);
|
||||
expect(own[0].username).toBe(a);
|
||||
// Internal ids must not reach a caller, as `toClientTeam` does for `id`.
|
||||
@@ -1148,10 +1217,14 @@ describe('TeamService', () => {
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < 8; page++) {
|
||||
const result = await service.listOwnAudit(team.uid, created.userId, {
|
||||
limit: 1,
|
||||
cursor,
|
||||
});
|
||||
const result = await service.listOwnAudit(
|
||||
team.uid,
|
||||
created.userId,
|
||||
{
|
||||
limit: 1,
|
||||
cursor,
|
||||
},
|
||||
);
|
||||
seen.push(...result.items.map((e) => e.action));
|
||||
cursor = result.cursor;
|
||||
if (!cursor) break;
|
||||
@@ -1237,6 +1310,77 @@ describe('TeamService', () => {
|
||||
expect(await server.stores.user.getById(created.userId)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leaves the seat usable when no transport could carry a code', async () => {
|
||||
// A gate nothing can lift: `/send-confirm-email` needs the transport
|
||||
// that is missing, so the demand would never be satisfiable.
|
||||
const { team } = await makeTeam();
|
||||
const email = server.clients.email;
|
||||
(server.clients as { email?: unknown }).email = undefined;
|
||||
try {
|
||||
const username = `notr_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
const row = await server.stores.user.getByProperty(
|
||||
'id',
|
||||
created.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(row?.email).toBe(`${username}@test.local`);
|
||||
expect(Boolean(row?.requires_email_confirmation)).toBe(false);
|
||||
} finally {
|
||||
(server.clients as { email?: unknown }).email = email;
|
||||
}
|
||||
});
|
||||
|
||||
it('lets the owner retarget an address the seat never confirmed', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const username = `retgt_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}-typo@test.local`,
|
||||
});
|
||||
const fixed = `${username}-right@test.local`;
|
||||
await service.updateSeatEmail(
|
||||
team.uid,
|
||||
owner.id,
|
||||
created.userId,
|
||||
fixed,
|
||||
);
|
||||
|
||||
const row = await server.stores.user.getByProperty(
|
||||
'id',
|
||||
created.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(row?.email).toBe(fixed);
|
||||
expect(row?.email_confirm_code).toBeTruthy();
|
||||
expect(Boolean(row?.email_confirmed)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to retarget an address the seat confirmed', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const username = `conf_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
await server.stores.user.update(created.userId, {
|
||||
email_confirmed: true,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateSeatEmail(
|
||||
team.uid,
|
||||
owner.id,
|
||||
created.userId,
|
||||
`${username}-other@test.local`,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
// -- notifications -------------------------------------------------
|
||||
|
||||
/** Captures what would go out, without standing up a transport. */
|
||||
@@ -1276,11 +1420,22 @@ describe('TeamService', () => {
|
||||
mail.restore();
|
||||
}
|
||||
|
||||
expect(mail.sent).toHaveLength(1);
|
||||
expect(mail.sent[0].to).toBe(`${username}@test.local`);
|
||||
// The account notice, and the code the address is confirmed with.
|
||||
expect(mail.sent).toHaveLength(2);
|
||||
for (const sent of mail.sent) {
|
||||
expect(sent.to).toBe(`${username}@test.local`);
|
||||
// The owner already has the credential; it never goes to an
|
||||
// address nobody has confirmed yet.
|
||||
expect(sent.html).not.toContain(created.temporaryPassword);
|
||||
}
|
||||
expect(mail.sent[0].html).toContain(username);
|
||||
// Nobody confirmed this address; the owner has the credential already.
|
||||
expect(mail.sent[0].html).not.toContain(created.temporaryPassword);
|
||||
|
||||
const row = await server.stores.user.getByProperty(
|
||||
'id',
|
||||
created.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(mail.sent[1].html).toContain(row!.email_confirm_code);
|
||||
});
|
||||
|
||||
it('sends nothing when no address is given', async () => {
|
||||
@@ -1402,5 +1557,4 @@ describe('TeamService', () => {
|
||||
expect(second.items).toHaveLength(1);
|
||||
expect(second.items[0]).not.toEqual(first.items[0]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'node:crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
USERNAME_MAX_LENGTH,
|
||||
@@ -25,8 +26,10 @@ import {
|
||||
} from '../../controllers/auth/AuthController.js';
|
||||
import { isReservedUsername } from '../../util/reservedUsernames.js';
|
||||
import type { EmailTemplateName } from '../../clients/email/templates.js';
|
||||
import { subscriptionSatisfies } from '../metering/enforcement.js';
|
||||
import { ORG_SEAT_FREE_SUBSCRIPTION } from '../metering/consts.js';
|
||||
import {
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
ORG_SEAT_RESOLVER_PRIORITY,
|
||||
} from '../metering/consts.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
|
||||
// A free team is small on purpose; paying widens it. Both overridable in config.
|
||||
@@ -90,6 +93,7 @@ export const AUDIT_ACTIVATE = 'activate';
|
||||
|
||||
/** Written before the row it names goes; `_keep` is what preserves it. */
|
||||
export const AUDIT_DELETE_ACCOUNT = 'delete_account';
|
||||
export const AUDIT_UPDATE_SEAT_EMAIL = 'update_seat_email';
|
||||
|
||||
/** A disclosure change, so it is recorded like anything else the team does. */
|
||||
export const AUDIT_DIRECTORY_ON = 'directory_enabled';
|
||||
@@ -142,6 +146,8 @@ export class TeamService extends PuterService {
|
||||
const seat = await this.stores.team.getOrgSeat(userId);
|
||||
return seat ? ORG_SEAT_FREE_SUBSCRIPTION : null;
|
||||
},
|
||||
// Ahead of anything read off the address, which nobody confirmed.
|
||||
{ priority: ORG_SEAT_RESOLVER_PRIORITY },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,12 +323,13 @@ export class TeamService extends PuterService {
|
||||
const override = Number(this.config.max_seats_per_team);
|
||||
if (Number.isFinite(override) && override > 0) return override;
|
||||
|
||||
const policy = await this.#ownerPolicy(ownerUserId);
|
||||
const planCap = Number(policy?.teamSeatCap);
|
||||
const plan = await this.#ownerPlan(ownerUserId);
|
||||
// A named cap is deliberate; only the fallback turns on paid.
|
||||
const planCap = Number(plan?.policy?.teamSeatCap);
|
||||
if (Number.isFinite(planCap) && planCap > 0) return planCap;
|
||||
|
||||
// A resolved policy outside the free set is a plan someone pays for.
|
||||
const paid = policy ? subscriptionSatisfies(policy.id, true) : false;
|
||||
// A plan bought, not one inferred for the owner from their address.
|
||||
const paid = plan?.paid ?? false;
|
||||
const key = paid
|
||||
? 'max_seats_per_team_paid'
|
||||
: 'max_seats_per_team_free';
|
||||
@@ -332,18 +339,21 @@ export class TeamService extends PuterService {
|
||||
}
|
||||
|
||||
/** Null when unreadable, which caps as free: over-provisioning is worse. */
|
||||
async #ownerPolicy(
|
||||
ownerUserId: number,
|
||||
): Promise<{ id: string; teamSeatCap?: number } | null> {
|
||||
async #ownerPlan(ownerUserId: number): Promise<{
|
||||
policy: { id: string; teamSeatCap?: number };
|
||||
paid: boolean;
|
||||
} | null> {
|
||||
try {
|
||||
const owner = await this.stores.user.getById(ownerUserId);
|
||||
if (!owner?.uuid) return null;
|
||||
// The whole row, not an id/uuid stub: a resolver may key on any
|
||||
// field, and one that misses makes the cap depend on whether
|
||||
// something else cached this user's plan first.
|
||||
return await this.services.metering.getActorSubscription({
|
||||
user: owner,
|
||||
} as never);
|
||||
// The whole row: a resolver may key on any field.
|
||||
const actor = { user: owner } as never;
|
||||
// Sequential: run together both miss the cache and can disagree.
|
||||
const policy =
|
||||
await this.services.metering.getActorSubscription(actor);
|
||||
const paid =
|
||||
await this.services.metering.actorHasPaidSubscription(actor);
|
||||
return { policy, paid };
|
||||
} catch (e) {
|
||||
console.warn('[team] seat cap plan lookup failed:', e);
|
||||
return null;
|
||||
@@ -883,18 +893,16 @@ export class TeamService extends PuterService {
|
||||
id === null ? null : (users.get(id)?.username ?? null);
|
||||
|
||||
return {
|
||||
items: page.items.map(
|
||||
(row): MemberActivityEntry => ({
|
||||
action: row.action,
|
||||
reason: row.reason,
|
||||
created_at: epochSeconds(row.created_at),
|
||||
username: name(row.user_id_keep),
|
||||
actor_username: name(row.actor_user_id),
|
||||
// Only a sign-in carries these; the shape stays uniform.
|
||||
ip: null,
|
||||
user_agent: null,
|
||||
}),
|
||||
),
|
||||
items: page.items.map((row): MemberActivityEntry => ({
|
||||
action: row.action,
|
||||
reason: row.reason,
|
||||
created_at: epochSeconds(row.created_at),
|
||||
username: name(row.user_id_keep),
|
||||
actor_username: name(row.actor_user_id),
|
||||
// Only a sign-in carries these; the shape stays uniform.
|
||||
ip: null,
|
||||
user_agent: null,
|
||||
})),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1012,14 +1020,25 @@ export class TeamService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
// Gated only when a code can reach the address the team claimed.
|
||||
const confirmCode =
|
||||
email && this.clients.email
|
||||
? String(crypto.randomInt(100000, 1000000))
|
||||
: null;
|
||||
const user = await this.stores.user.create({
|
||||
username: input.username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: email || null,
|
||||
clean_email: email ? cleanEmail(email) : null,
|
||||
// Never demanded: the team creating the account is the trust anchor.
|
||||
requires_email_confirmation: false,
|
||||
requires_email_confirmation: Boolean(confirmCode),
|
||||
email_confirmed: false,
|
||||
...(confirmCode
|
||||
? {
|
||||
email_confirm_code: confirmCode,
|
||||
email_confirm_token: uuidv4(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
await generateDefaultFsentries(this.clients.db, this.stores.user, user);
|
||||
@@ -1045,6 +1064,12 @@ export class TeamService extends PuterService {
|
||||
// Not mailed: nobody confirmed the address the owner typed in.
|
||||
const temporaryPassword = await this.#issueTemporaryPassword(user.id);
|
||||
await this.#notifyUser(user, 'team_account_created', team);
|
||||
// The seat cannot confirm a code it was never sent.
|
||||
if (confirmCode) {
|
||||
await this.#notifyUser(user, 'email_verification_code', team, {
|
||||
code: confirmCode,
|
||||
});
|
||||
}
|
||||
|
||||
// Last: the seat is only chargeable once it exists and can be used.
|
||||
this.#emitBilling('team.account.created', {
|
||||
@@ -1130,6 +1155,61 @@ export class TeamService extends PuterService {
|
||||
return { temporaryPassword };
|
||||
}
|
||||
|
||||
/** Retarget a seat's unconfirmed address; a confirmed one is theirs. */
|
||||
async updateSeatEmail(
|
||||
teamUid: string,
|
||||
actorUserId: number,
|
||||
targetUserId: number,
|
||||
rawEmail: unknown,
|
||||
): Promise<void> {
|
||||
const team = await this.requireOwner(teamUid, actorUserId);
|
||||
const user = await this.#requireTargetAccount(teamUid, targetUserId);
|
||||
if (user.email && user.email_confirmed) {
|
||||
throw new HttpError(409, 'That address is already confirmed', {
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
}
|
||||
|
||||
const email = typeof rawEmail === 'string' ? rawEmail.trim() : '';
|
||||
if (email && !isStorableEmail(email)) {
|
||||
throw new HttpError(400, 'Invalid email', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
if (email && (await this.stores.user.findEmailOwner(email))) {
|
||||
throw new HttpError(409, 'That email is already in use', {
|
||||
legacyCode: 'email_already_in_use',
|
||||
});
|
||||
}
|
||||
|
||||
const confirmCode =
|
||||
email && this.clients.email
|
||||
? String(crypto.randomInt(100000, 1000000))
|
||||
: null;
|
||||
await this.stores.user.update(targetUserId, {
|
||||
email: email || null,
|
||||
clean_email: email ? cleanEmail(email) : null,
|
||||
requires_email_confirmation: Boolean(confirmCode),
|
||||
email_confirmed: false,
|
||||
email_confirm_code: confirmCode,
|
||||
email_confirm_token: confirmCode ? uuidv4() : null,
|
||||
});
|
||||
await this.stores.team.appendAudit({
|
||||
teamId: team.id,
|
||||
userId: targetUserId,
|
||||
actorUserId,
|
||||
action: AUDIT_UPDATE_SEAT_EMAIL,
|
||||
});
|
||||
if (confirmCode) {
|
||||
await this.#notifyUser(
|
||||
{ ...user, email } as UserRow,
|
||||
'email_verification_code',
|
||||
team,
|
||||
{ code: confirmCode },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a seat's second factor so it can enrol again, for the member who
|
||||
* lost both their authenticator and their recovery codes.
|
||||
|
||||
@@ -667,7 +667,8 @@ export class UserStore extends PuterStore {
|
||||
|
||||
/**
|
||||
* Remove an address the account never confirmed. Guarded so an address
|
||||
* confirmed in between stays. Returns false when nothing was removed.
|
||||
* confirmed in between stays. Returns false when nothing was removed. The
|
||||
* demand goes too: it names the address, and outlives it otherwise.
|
||||
*/
|
||||
async clearUnconfirmedEmail(userId: number): Promise<boolean> {
|
||||
const unconfirmed =
|
||||
@@ -675,7 +676,13 @@ export class UserStore extends PuterStore {
|
||||
this.clients.db.booleanLiteral(false);
|
||||
return this.#write(
|
||||
userId,
|
||||
{ email: null, clean_email: null },
|
||||
{
|
||||
email: null,
|
||||
clean_email: null,
|
||||
requires_email_confirmation: false,
|
||||
email_confirm_code: null,
|
||||
email_confirm_token: null,
|
||||
},
|
||||
unconfirmed,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,12 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
h += `<img src="${html_encode(window.icons['mail.svg'])}" style="display:block; margin:10px auto 10px;">`;
|
||||
h += `<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${i18n('confirm_your_email_address')}</h3>`;
|
||||
h += '<form>';
|
||||
h += `<p style="text-align:center; padding: 0 20px;">To continue, please enter the 6-digit confirmation code sent to <strong style="font-weight: 500;">${window.user.email}</strong></p>`;
|
||||
// Opens at boot before the profile has loaded, and an address we do not
|
||||
// have yet is no reason to fail to ask for the code.
|
||||
const sent_to = window.user?.email
|
||||
? `${i18n('email_code_sent_to')} <strong style="font-weight: 500;">${html_encode(window.user.email)}</strong>`
|
||||
: i18n('email_code_sent_to_your_address');
|
||||
h += `<p style="text-align:center; padding: 0 20px;">${sent_to}</p>`;
|
||||
h += '<div class="error"></div>';
|
||||
h += ` <fieldset name="number-code" style="border: none; padding:0;" data-number-code-form>
|
||||
<input class="digit-input" type="number" min='0' max='9' name='number-code-0' data-number-code-input='0' required />
|
||||
|
||||
@@ -377,6 +377,8 @@ const en = {
|
||||
repeat: 'Repeat',
|
||||
replace: 'Replace',
|
||||
replace_all: 'Replace All',
|
||||
email_code_sent_to: 'To continue, please enter the 6-digit confirmation code sent to',
|
||||
email_code_sent_to_your_address: 'To continue, please enter the 6-digit confirmation code sent to your email address',
|
||||
resend_confirmation_code: 'Re-send Confirmation Code',
|
||||
reset_colors: 'Reset Colors',
|
||||
'Resources': 'Resources',
|
||||
|
||||
Reference in new issue
Block a user