refactor: remove is_temporary flag across the entire codebase
Drop the is_temporary boolean from the DB schema (new migration), types, API routers/helpers/middleware, and all frontend components and tests. Access control now relies solely on is_client. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a60cd7739a
commit
c56d5718b8
30 changed files with 33 additions and 297 deletions
|
|
@ -11,7 +11,6 @@ export interface TestUser {
|
|||
first_name: string;
|
||||
last_name: string;
|
||||
name: string;
|
||||
is_temporary: boolean;
|
||||
}
|
||||
|
||||
export const TEST_USERS: Record<string, TestUser> = {
|
||||
|
|
@ -21,7 +20,6 @@ export const TEST_USERS: Record<string, TestUser> = {
|
|||
first_name: "Test",
|
||||
last_name: "Owner",
|
||||
name: "Test Owner",
|
||||
is_temporary: false,
|
||||
},
|
||||
temp: {
|
||||
email: "test_temp@example.com",
|
||||
|
|
@ -29,7 +27,6 @@ export const TEST_USERS: Record<string, TestUser> = {
|
|||
first_name: "Test",
|
||||
last_name: "Temporary",
|
||||
name: "Test Temporary",
|
||||
is_temporary: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,14 +27,12 @@ describe("billing helpers", () => {
|
|||
{
|
||||
id: "owner-user",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
plan: "annual",
|
||||
},
|
||||
{
|
||||
id: "late-user",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
plan: "solo",
|
||||
},
|
||||
|
|
@ -43,26 +41,23 @@ describe("billing helpers", () => {
|
|||
expect(owner?.id).toBe("owner-user");
|
||||
});
|
||||
|
||||
it("excludes temporary users from billable seat count", () => {
|
||||
it("excludes client users from billable seat count", () => {
|
||||
const count = getBillableMemberCount([
|
||||
{
|
||||
id: "user-1",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
plan: "solo",
|
||||
},
|
||||
{
|
||||
id: "temp-1",
|
||||
id: "client-1",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: true,
|
||||
is_client: false,
|
||||
is_client: true,
|
||||
plan: "solo",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
created_at: "2026-01-03T10:00:00.000Z",
|
||||
is_temporary: null,
|
||||
is_client: false,
|
||||
plan: "team",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -129,19 +129,6 @@ export async function setupTestDatabase(): Promise<TestDatabaseData> {
|
|||
accessToken: signInData.session.access_token,
|
||||
};
|
||||
|
||||
// Update profile with is_temporary flag if needed
|
||||
if (userData.is_temporary) {
|
||||
const { error: profileError } = await adminClient
|
||||
.from("profiles")
|
||||
.update({ is_temporary: true })
|
||||
.eq("id", authData.user.id);
|
||||
|
||||
if (profileError) {
|
||||
console.warn(
|
||||
`Warning: Failed to update profile for ${userData.email}: ${profileError.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tablosToInsert = TEST_TABLOS.map((tablo) => ({
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ describe("Middleware Tests", () => {
|
|||
const middlewareManager = MiddlewareManager.getInstance();
|
||||
|
||||
const createProfilesSupabaseMock = (result: {
|
||||
data: { is_temporary?: boolean; is_client?: boolean } | null;
|
||||
data: { is_client?: boolean } | null;
|
||||
error: { message: string } | null;
|
||||
}) => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
|
|
@ -314,33 +314,6 @@ describe("Middleware Tests", () => {
|
|||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject temporary users", async () => {
|
||||
const app = new Hono();
|
||||
app.use(async (c, next) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
|
||||
(c as any).set(
|
||||
"supabase",
|
||||
createProfilesSupabaseMock({
|
||||
data: { is_temporary: true },
|
||||
error: null,
|
||||
})
|
||||
);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
|
||||
(c as any).set("user", { id: "temp-user" });
|
||||
await next();
|
||||
});
|
||||
app.use(middlewareManager.regularUserCheck);
|
||||
app.get("/test", (c) => c.json({ success: true }));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
const client = testClient(app) as any;
|
||||
const res = await client.test.$get();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(data.error).toBe("User is read only");
|
||||
});
|
||||
|
||||
it("should return 401 for client users", async () => {
|
||||
const app = new Hono();
|
||||
app.use(async (c, next) => {
|
||||
|
|
@ -348,7 +321,7 @@ describe("Middleware Tests", () => {
|
|||
(c as any).set(
|
||||
"supabase",
|
||||
createProfilesSupabaseMock({
|
||||
data: { is_temporary: false, is_client: true },
|
||||
data: { is_client: true },
|
||||
error: null,
|
||||
})
|
||||
);
|
||||
|
|
@ -369,34 +342,6 @@ describe("Middleware Tests", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Billing Checkout Access Middleware", () => {
|
||||
it("should allow temporary users to continue to billing checkout", async () => {
|
||||
const app = new Hono();
|
||||
app.use(async (c, next) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
|
||||
(c as any).set(
|
||||
"supabase",
|
||||
createProfilesSupabaseMock({
|
||||
data: { is_temporary: true },
|
||||
error: null,
|
||||
})
|
||||
);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
|
||||
(c as any).set("user", { id: "temp-user" });
|
||||
await next();
|
||||
});
|
||||
app.use(middlewareManager.billingCheckoutAccess);
|
||||
app.get("/test", (c) => c.json({ success: true }));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
const client = testClient(app) as any;
|
||||
const res = await client.test.$get();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Active Plan Access Middleware", () => {
|
||||
it("should reject requests when the organization has no active plan", async () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export type RequiredBillingPlan = "solo" | "team";
|
|||
type BillingProfileRow = {
|
||||
id: string;
|
||||
created_at: string | null;
|
||||
is_temporary: boolean | null;
|
||||
is_client: boolean | null;
|
||||
plan: string | null;
|
||||
};
|
||||
|
|
@ -88,7 +87,7 @@ export const parseTrialRolloutDate = (
|
|||
export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null;
|
||||
|
||||
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
|
||||
profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length;
|
||||
profiles.filter((profile) => profile.is_client !== true).length;
|
||||
|
||||
export const getTrialWindow = (input: {
|
||||
ownerCreatedAt: Date;
|
||||
|
|
@ -180,7 +179,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo
|
|||
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, created_at, is_temporary, is_client, plan")
|
||||
.select("id, created_at, is_client, plan")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -292,11 +292,7 @@ export const createInvitedUser = async (
|
|||
transporter: Transporter,
|
||||
recipientEmail: string,
|
||||
senderEmail: string,
|
||||
options?: {
|
||||
isTemporary?: boolean;
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string; userId?: string }> => {
|
||||
const isTemporary = options?.isTemporary ?? true;
|
||||
const xtabloUrl = process.env.XTABLO_URL || "https://app.xtablo.com";
|
||||
|
||||
// Create a new user account for the invited email
|
||||
|
|
@ -322,16 +318,6 @@ export const createInvitedUser = async (
|
|||
return { success: false, error: createUserError.message };
|
||||
}
|
||||
|
||||
const { error: updateProfileError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ is_temporary: isTemporary })
|
||||
.eq("id", newUser.user.id);
|
||||
|
||||
if (updateProfileError) {
|
||||
console.error("Error setting invited user temporary status:", updateProfileError);
|
||||
return { success: false, error: updateProfileError.message };
|
||||
}
|
||||
|
||||
// Send welcome email to the new user
|
||||
await transporter.sendMail({
|
||||
from: `${senderEmail} via XTablo <noreply@xtablo.com>`,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export class MiddlewareManager {
|
|||
result: AdminTokenResult<T>
|
||||
): result is Extract<AdminTokenResult<T>, { success: false }> => !result.success;
|
||||
|
||||
const createProfileAccessMiddleware = (allowTemporaryUsers: boolean) =>
|
||||
const createProfileAccessMiddleware = () =>
|
||||
createMiddleware<{
|
||||
Variables: { supabase: SupabaseClient; user: User };
|
||||
Bindings: { user: User };
|
||||
|
|
@ -92,7 +92,7 @@ export class MiddlewareManager {
|
|||
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("is_temporary, is_client")
|
||||
.select("is_client")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ export class MiddlewareManager {
|
|||
return c.json({ error: error?.message ?? "Profile not found" }, 500);
|
||||
}
|
||||
|
||||
if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) {
|
||||
if (profile.is_client) {
|
||||
return c.json({ error: "User is read only" }, 401);
|
||||
}
|
||||
|
||||
|
|
@ -217,8 +217,8 @@ export class MiddlewareManager {
|
|||
await next();
|
||||
});
|
||||
|
||||
const regularUserCheckMiddleware = createProfileAccessMiddleware(false);
|
||||
const billingCheckoutAccessMiddleware = createProfileAccessMiddleware(true);
|
||||
const regularUserCheckMiddleware = createProfileAccessMiddleware();
|
||||
const billingCheckoutAccessMiddleware = createProfileAccessMiddleware();
|
||||
const activePlanAccessMiddleware = createMiddleware<{
|
||||
Variables: { supabase: SupabaseClient; user: User };
|
||||
Bindings: { user: User };
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ export const getAdminOverviewRouter = () => {
|
|||
recentTablos,
|
||||
activeAccess,
|
||||
adminAccess,
|
||||
temporaryUsers,
|
||||
inactiveAccess,
|
||||
] = await Promise.all([
|
||||
countRows(supabase.from("profiles").select("*", { count: "exact", head: true })),
|
||||
|
|
@ -68,12 +67,6 @@ export const getAdminOverviewRouter = () => {
|
|||
.eq("is_active", true)
|
||||
.eq("is_admin", true)
|
||||
),
|
||||
countRows(
|
||||
supabase
|
||||
.from("profiles")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("is_temporary", true)
|
||||
),
|
||||
countRows(
|
||||
supabase
|
||||
.from("tablo_access")
|
||||
|
|
@ -84,12 +77,6 @@ export const getAdminOverviewRouter = () => {
|
|||
|
||||
const response: AdminOverviewResponse = {
|
||||
alerts: [
|
||||
{
|
||||
description: `${temporaryUsers} temporary users still exist in production.`,
|
||||
id: "temporary-users",
|
||||
severity: temporaryUsers > 0 ? "warning" : "info",
|
||||
title: "Temporary Accounts",
|
||||
},
|
||||
{
|
||||
description: `${inactiveAccess} tablo access rows are inactive and may need review.`,
|
||||
id: "inactive-access",
|
||||
|
|
|
|||
|
|
@ -56,8 +56,7 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
supabase,
|
||||
transporter,
|
||||
data.user_details.email,
|
||||
ownerData.email,
|
||||
{ isTemporary: true }
|
||||
ownerData.email
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -208,9 +208,7 @@ const inviteToTablo = (
|
|||
|
||||
if (!recipientUser) {
|
||||
// Create a new invited user and add them to the tablo
|
||||
const result = await createInvitedUser(supabase, transporter, recipientEmail, sender.email, {
|
||||
isTemporary: true,
|
||||
});
|
||||
const result = await createInvitedUser(supabase, transporter, recipientEmail, sender.email);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: result.error }, 500);
|
||||
|
|
@ -340,12 +338,11 @@ const cancelPendingInvite = (middlewareManager: ReturnType<typeof MiddlewareMana
|
|||
|
||||
const { data: invitedProfile } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, is_temporary")
|
||||
.select("id")
|
||||
.eq("email", invite.invited_email)
|
||||
.maybeSingle();
|
||||
|
||||
// Temporary invitees are pre-added to tablo_access. Revoke this access when invite is cancelled.
|
||||
if (invitedProfile?.id && invitedProfile.is_temporary) {
|
||||
if (invitedProfile?.id) {
|
||||
const { error: revokeAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.update({ is_active: false })
|
||||
|
|
|
|||
|
|
@ -42,84 +42,6 @@ const getMe = factory.createHandlers(async (c) => {
|
|||
return c.json({ ...userData, plan: effectivePlan });
|
||||
});
|
||||
|
||||
const markTemporary = factory.createHandlers(async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
|
||||
const body = await c.req.json();
|
||||
const { temporary_password } = body;
|
||||
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
is_temporary: true,
|
||||
})
|
||||
.eq("id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
const transporter = c.get("transporter");
|
||||
|
||||
try {
|
||||
if (profile?.email && transporter) {
|
||||
const mailOptions = {
|
||||
from: "Xtablo <noreply@xtablo.com>",
|
||||
to: profile.email,
|
||||
subject: "Bienvenue sur XTablo - Votre mot de passe temporaire",
|
||||
text: `Bienvenue sur XTablo !
|
||||
|
||||
Votre compte a été créé avec succès. Voici vos informations de connexion :
|
||||
|
||||
Email : ${profile.email}
|
||||
Mot de passe temporaire : ${temporary_password}
|
||||
|
||||
Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.
|
||||
|
||||
Connectez-vous sur : ${process.env.FRONTEND_URL || "https://app.xtablo.com"}
|
||||
|
||||
Cordialement,
|
||||
L'équipe XTablo`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Bienvenue sur XTablo !</h2>
|
||||
|
||||
<p>Votre compte a été créé avec succès. Voici vos informations de connexion :</p>
|
||||
|
||||
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Email :</strong> ${profile.email}</p>
|
||||
<p><strong>Mot de passe temporaire :</strong> <code style="background-color: #e1e1e1; padding: 2px 4px; border-radius: 3px;">${temporary_password}</code></p>
|
||||
</div>
|
||||
|
||||
<p style="color: #d9534f; margin-bottom: 20px;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
|
||||
|
||||
<p>
|
||||
<a href="${process.env.FRONTEND_URL || "https://app.tablo.com"}"
|
||||
style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
Se connecter à XTablo
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 14px; margin-top: 30px;">
|
||||
Cordialement,<br>
|
||||
L'équipe XTablo
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send welcome email:", error);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "User marked as temporary",
|
||||
});
|
||||
});
|
||||
|
||||
// userRouter.put("/profile", async (c) => {
|
||||
// const user = c.get("user");
|
||||
|
|
@ -278,7 +200,7 @@ const getOrganization = factory.createHandlers(async (c) => {
|
|||
|
||||
const { data: members, error: membersError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, email, name, first_name, last_name, avatar_url, created_at, is_temporary, plan")
|
||||
.select("id, email, name, first_name, last_name, avatar_url, created_at, plan")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
|
|
@ -418,7 +340,7 @@ const updateOrganization = factory.createHandlers(async (c) => {
|
|||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id, is_temporary")
|
||||
.select("organization_id")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
|
|
@ -426,10 +348,6 @@ const updateOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "Failed to resolve your organization" }, 500);
|
||||
}
|
||||
|
||||
if (profile.is_temporary) {
|
||||
return c.json({ error: "Temporary users cannot update organization settings" }, 403);
|
||||
}
|
||||
|
||||
const organizationId = profile.organization_id;
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
|
|
@ -497,7 +415,7 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
|
||||
const { data: senderProfile, error: senderError } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id, email, is_temporary")
|
||||
.select("organization_id, email")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
|
|
@ -505,10 +423,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "Failed to resolve your organization" }, 500);
|
||||
}
|
||||
|
||||
if (senderProfile.is_temporary) {
|
||||
return c.json({ error: "Temporary users cannot invite organization members" }, 403);
|
||||
}
|
||||
|
||||
if (senderProfile.email.toLowerCase() === recipientEmail) {
|
||||
return c.json({ error: "You cannot invite yourself" }, 400);
|
||||
}
|
||||
|
|
@ -587,7 +501,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
transporter,
|
||||
recipientEmail,
|
||||
senderProfile.email,
|
||||
{ isTemporary: false }
|
||||
);
|
||||
|
||||
if (!invitedUser.success || !invitedUser.userId) {
|
||||
|
|
@ -608,7 +521,7 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
|
||||
const { error: assignOrganizationError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ organization_id: organizationId, is_temporary: false })
|
||||
.update({ organization_id: organizationId })
|
||||
.eq("id", invitedUser.userId);
|
||||
|
||||
if (assignOrganizationError) {
|
||||
|
|
@ -686,7 +599,7 @@ const removeOrganizationMember = factory.createHandlers(async (c) => {
|
|||
|
||||
const { data: actorProfile, error: actorProfileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id, is_temporary")
|
||||
.select("organization_id")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
|
|
@ -694,10 +607,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "Failed to resolve your organization" }, 500);
|
||||
}
|
||||
|
||||
if (actorProfile.is_temporary) {
|
||||
return c.json({ error: "Temporary users cannot manage organization members" }, 403);
|
||||
}
|
||||
|
||||
const organizationId = actorProfile.organization_id;
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
|
|
@ -875,7 +784,6 @@ export const getUserRouter = () => {
|
|||
|
||||
userRouter.get("/me", ...getMe);
|
||||
userRouter.delete("/me", ...deleteMe);
|
||||
userRouter.post("/mark-temporary", ...markTemporary);
|
||||
userRouter.post("/profile/avatar", ...uploadAvatar);
|
||||
userRouter.delete("/profile/avatar", ...deleteAvatar);
|
||||
userRouter.get("/organization", ...getOrganization);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ describe("AuthenticationGateway", () => {
|
|||
last_name: "User",
|
||||
email: "client@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: true,
|
||||
client_onboarded_at: new Date().toISOString(),
|
||||
last_signed_in: null,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ describe("PublicRoute", () => {
|
|||
last_name: "User",
|
||||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
|
|
@ -109,7 +108,6 @@ describe("PublicRoute", () => {
|
|||
last_name: "User",
|
||||
email: "client@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: true,
|
||||
client_onboarded_at: new Date().toISOString(),
|
||||
last_signed_in: null,
|
||||
|
|
|
|||
|
|
@ -462,7 +462,6 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
organizationData.active_subscription_quantity >= organizationData.required_team_quantity);
|
||||
|
||||
const shouldShowSoloUpsell =
|
||||
!user.is_temporary &&
|
||||
!!organizationData &&
|
||||
organizationData.required_plan === "team" &&
|
||||
!hasCompliantTeamPlan;
|
||||
|
|
|
|||
|
|
@ -92,7 +92,6 @@ describe("ProtectedRoute", () => {
|
|||
short_user_id: "123",
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
|
|
@ -148,7 +147,6 @@ describe("ProtectedRoute", () => {
|
|||
short_user_id: "123",
|
||||
first_name: "Client",
|
||||
last_name: "User",
|
||||
is_temporary: false,
|
||||
is_client: true,
|
||||
client_onboarded_at: new Date().toISOString(),
|
||||
last_signed_in: null,
|
||||
|
|
|
|||
|
|
@ -6,17 +6,12 @@ import { useMaybeUser } from "../providers/UserStoreProvider";
|
|||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
// Fallback URL
|
||||
fallback?: string;
|
||||
// Only allow regular users (not temporary)
|
||||
onlyRegularUser?: boolean;
|
||||
// Redirect to current page
|
||||
shouldRedirectToCurrentPage?: boolean;
|
||||
}
|
||||
|
||||
export const ProtectedRoute = ({
|
||||
fallback,
|
||||
onlyRegularUser,
|
||||
shouldRedirectToCurrentPage,
|
||||
}: ProtectedRouteProps) => {
|
||||
const user = useMaybeUser();
|
||||
|
|
@ -46,8 +41,6 @@ export const ProtectedRoute = ({
|
|||
status = "should-redirect";
|
||||
} else if (user.is_client) {
|
||||
status = "should-redirect-client";
|
||||
} else if (onlyRegularUser && user.is_temporary) {
|
||||
status = "should-redirect";
|
||||
} else {
|
||||
status = "should-pass";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ const baseUser: User = {
|
|||
last_name: "User",
|
||||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ describe("shouldShowTrialUpsell", () => {
|
|||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: 14,
|
||||
})
|
||||
).toBe(false);
|
||||
|
|
@ -18,7 +17,6 @@ describe("shouldShowTrialUpsell", () => {
|
|||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: 3,
|
||||
})
|
||||
).toBe(true);
|
||||
|
|
@ -29,7 +27,6 @@ describe("shouldShowTrialUpsell", () => {
|
|||
shouldShowTrialUpsell({
|
||||
isTrialExpired: true,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: null,
|
||||
})
|
||||
).toBe(false);
|
||||
|
|
@ -40,20 +37,9 @@ describe("shouldShowTrialUpsell", () => {
|
|||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: "team",
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: 2,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show for temporary users", () => {
|
||||
expect(
|
||||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: true,
|
||||
daysRemaining: 2,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,12 +21,11 @@ const TRIAL_UPSELL_REMINDER_DAYS = 7;
|
|||
export const shouldShowTrialUpsell = (input: {
|
||||
isTrialExpired: boolean;
|
||||
activeSubscriptionPlan: "solo" | "team" | "annual" | null;
|
||||
isTemporaryUser: boolean;
|
||||
daysRemaining: number | null;
|
||||
}) => {
|
||||
const { isTrialExpired, activeSubscriptionPlan, isTemporaryUser, daysRemaining } = input;
|
||||
const { isTrialExpired, activeSubscriptionPlan, daysRemaining } = input;
|
||||
|
||||
if (isTrialExpired || activeSubscriptionPlan || isTemporaryUser) {
|
||||
if (isTrialExpired || activeSubscriptionPlan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +52,6 @@ export function TrialUpsellModal() {
|
|||
shouldShowTrialUpsell({
|
||||
isTrialExpired: organizationData.is_trial_expired,
|
||||
activeSubscriptionPlan: organizationData.active_subscription_plan,
|
||||
isTemporaryUser: Boolean(user?.is_temporary),
|
||||
daysRemaining,
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ const baseUser: User = {
|
|||
last_name: "User",
|
||||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
|
|
@ -108,12 +107,6 @@ describe("UpgradePanel", () => {
|
|||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing for temporary users even with no plan", () => {
|
||||
const tempUser = { ...baseUser, is_temporary: true };
|
||||
const { container } = renderPanel(tempUser, noPlanOrg);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders the paywall for regular users with no plan", () => {
|
||||
renderPanel(baseUser, noPlanOrg);
|
||||
expect(screen.getByText("Choisissez un abonnement pour continuer")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ const baseUser: User = {
|
|||
last_name: "User",
|
||||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
|
|
@ -118,19 +117,6 @@ describe("UpgradeBlockProvider", () => {
|
|||
expect(screen.getByTestId("blocked").textContent).toBe("false");
|
||||
});
|
||||
|
||||
it("is not blocked for temporary users regardless of org billing state", () => {
|
||||
const temporaryUser = { ...baseUser, is_temporary: true };
|
||||
renderWithUser(temporaryUser, noPlanOrgData);
|
||||
expect(screen.getByTestId("blocked").textContent).toBe("false");
|
||||
expect(screen.getByTestId("reason").textContent).toBe("none");
|
||||
});
|
||||
|
||||
it("is not blocked for temporary users even with expired trial", () => {
|
||||
const temporaryUser = { ...baseUser, is_temporary: true };
|
||||
renderWithUser(temporaryUser, trialExpiredOrgData);
|
||||
expect(screen.getByTestId("blocked").textContent).toBe("false");
|
||||
});
|
||||
|
||||
it("blocks regular users when org has no active plan", () => {
|
||||
renderWithUser(baseUser, noPlanOrgData);
|
||||
expect(screen.getByTestId("blocked").textContent).toBe("true");
|
||||
|
|
|
|||
|
|
@ -28,20 +28,16 @@ export const UpgradeBlockProvider: React.FC<UpgradeBlockProviderProps> = ({ chil
|
|||
const { data: organizationData } = useOrganization();
|
||||
const user = useMaybeUser();
|
||||
|
||||
// Wait for both user and organization data before deciding.
|
||||
// Temporary users are never blocked — they have read-only access enforced at the API level.
|
||||
const reason =
|
||||
!user || !organizationData
|
||||
? null
|
||||
: user.is_temporary
|
||||
? null
|
||||
: getOrganizationUpgradeBlockReason({
|
||||
is_trial_expired: organizationData.is_trial_expired,
|
||||
required_plan: organizationData.required_plan,
|
||||
required_team_quantity: organizationData.required_team_quantity,
|
||||
active_subscription_plan: organizationData.active_subscription_plan,
|
||||
active_subscription_quantity: organizationData.active_subscription_quantity,
|
||||
});
|
||||
: getOrganizationUpgradeBlockReason({
|
||||
is_trial_expired: organizationData.is_trial_expired,
|
||||
required_plan: organizationData.required_plan,
|
||||
required_team_quantity: organizationData.required_team_quantity,
|
||||
active_subscription_plan: organizationData.active_subscription_plan,
|
||||
active_subscription_quantity: organizationData.active_subscription_quantity,
|
||||
});
|
||||
|
||||
const isBlocked = reason !== null;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export interface OrganizationMember {
|
|||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
created_at: string | null;
|
||||
is_temporary: boolean;
|
||||
plan: string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ const testUser: User = {
|
|||
last_name: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ describe("TestUserStoreProvider", () => {
|
|||
avatar_url: null,
|
||||
email: null,
|
||||
first_name: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_name: null,
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export const UserStoreProvider = ({ children }: { children: React.ReactNode }) =
|
|||
email: user.email ?? undefined,
|
||||
name:
|
||||
user.first_name && user.last_name ? `${user.first_name} ${user.last_name}` : undefined,
|
||||
isReadOnly: user.is_temporary,
|
||||
isReadOnly: false,
|
||||
});
|
||||
} else {
|
||||
datadogRum.clearUser();
|
||||
|
|
@ -144,7 +144,7 @@ export const useIsReadOnlyUser = () => {
|
|||
if (!store) {
|
||||
throw new Error("Missing UserStoreProvider");
|
||||
}
|
||||
return useStore(store).is_temporary;
|
||||
return false;
|
||||
};
|
||||
|
||||
// TestUserStoreProvider component
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ const defaultUser = {
|
|||
last_name: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar_url: "https://example.com/avatar.jpg",
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
|
|
|
|||
|
|
@ -447,7 +447,6 @@ export type Database = {
|
|||
first_name: string | null;
|
||||
id: string;
|
||||
is_client: boolean;
|
||||
is_temporary: boolean;
|
||||
last_name: string | null;
|
||||
last_signed_in: string | null;
|
||||
name: string | null;
|
||||
|
|
@ -462,7 +461,6 @@ export type Database = {
|
|||
first_name?: string | null;
|
||||
id: string;
|
||||
is_client?: boolean;
|
||||
is_temporary?: boolean;
|
||||
last_name?: string | null;
|
||||
last_signed_in?: string | null;
|
||||
name?: string | null;
|
||||
|
|
@ -477,7 +475,6 @@ export type Database = {
|
|||
first_name?: string | null;
|
||||
id?: string;
|
||||
is_client?: boolean;
|
||||
is_temporary?: boolean;
|
||||
last_name?: string | null;
|
||||
last_signed_in?: string | null;
|
||||
name?: string | null;
|
||||
|
|
|
|||
1
supabase/migrations/20260430120000_drop_is_temporary.sql
Normal file
1
supabase/migrations/20260430120000_drop_is_temporary.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public.profiles DROP COLUMN IF EXISTS is_temporary;
|
||||
|
|
@ -391,7 +391,6 @@ export type Database = {
|
|||
email: string | null
|
||||
first_name: string | null
|
||||
id: string
|
||||
is_temporary: boolean
|
||||
last_name: string | null
|
||||
last_signed_in: string | null
|
||||
name: string | null
|
||||
|
|
@ -404,7 +403,6 @@ export type Database = {
|
|||
email?: string | null
|
||||
first_name?: string | null
|
||||
id: string
|
||||
is_temporary?: boolean
|
||||
last_name?: string | null
|
||||
last_signed_in?: string | null
|
||||
name?: string | null
|
||||
|
|
@ -417,7 +415,6 @@ export type Database = {
|
|||
email?: string | null
|
||||
first_name?: string | null
|
||||
id?: string
|
||||
is_temporary?: boolean
|
||||
last_name?: string | null
|
||||
last_signed_in?: string | null
|
||||
name?: string | null
|
||||
|
|
|
|||
Loading…
Reference in a new issue