Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b9df12b
feat: tos schemas
jona159 Mar 2, 2026
922694a
feat: translations
jona159 Mar 2, 2026
0e7647e
feat: add tos to user schema
jona159 Mar 2, 2026
029aa43
feat: enable middleware
jona159 Mar 2, 2026
a4e472f
feat: tos server
jona159 Mar 2, 2026
c8b2822
feat: add tos acceptance to user creation
jona159 Mar 2, 2026
0246ff1
feat: add terms to menu
jona159 Mar 3, 2026
6ff6b88
feat: dialog component for tos modal
jona159 Mar 3, 2026
3ea6b96
feat: require tos acceptance on register
jona159 Mar 3, 2026
6a0a883
feat: ui and api middlewares
jona159 Mar 3, 2026
4c90204
feat: adjust register via api for tos requirement
jona159 Mar 3, 2026
698fbbc
feat: tos validation
jona159 Mar 3, 2026
0bbbe97
feat: terms component
jona159 Mar 3, 2026
6f57d7c
feat: allow to accept tos via api
jona159 Mar 3, 2026
14144b9
fix: typo, add device deletion page
jona159 Mar 3, 2026
34c49f3
feat: delete device component
jona159 Mar 3, 2026
6fea65a
feat: translations
jona159 Mar 3, 2026
2f6403f
feat: translations
jona159 Mar 3, 2026
53e2da8
feat: get user device
jona159 Mar 3, 2026
49fd12b
fix: rm device deletion from general edit
jona159 Mar 3, 2026
82327d9
fix: style
jona159 Mar 3, 2026
5af63d9
feat: allow profile and delete device in ui
jona159 Mar 3, 2026
61f509f
feat: add delete action
jona159 Mar 3, 2026
a3ed19a
fix: tests
jona159 Mar 3, 2026
4d21408
fix: tests
jona159 Mar 3, 2026
d9fef16
Revert "fix: rm device deletion from general edit"
jona159 Mar 4, 2026
e3a153c
fix: rename to effective from
jona159 Mar 4, 2026
5ff6d7e
fix: rm delete from menu
jona159 Mar 4, 2026
77eedc2
fix: rm delete from sidebar
jona159 Mar 4, 2026
3abf816
feat: delete devices of users who delete their account
jona159 Mar 4, 2026
ce674cb
feat: improve api middleware allowlist
jona159 Mar 4, 2026
a30a5c8
fix: more tests
jona159 Mar 4, 2026
510f27d
fix: remove device deletion from allowlist
jona159 Mar 4, 2026
8356bfd
fix: remove device deletion from allowlist
jona159 Mar 4, 2026
9ec9572
Merge branch 'dev' into feat/tos
jona159 Mar 4, 2026
fc3a5c3
fix: migrations
jona159 Mar 4, 2026
a7e96ae
fix: minor
jona159 Mar 4, 2026
0869dea
fix: migrations
jona159 Mar 9, 2026
24a1b4c
feat: adjust tos schema
jona159 Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/components/header/menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ExternalLink,
Settings,
Compass,
ScrollText,
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
Expand Down Expand Up @@ -153,6 +154,23 @@ export default function Menu() {
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuGroup>
<Link
to={
'/terms'
}
target="_blank"
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<ScrollText className="mr-2 inline h-5 w-5" />
<span> {t('tos')}</span>
<ExternalLink className="ml-auto h-4 w-4 text-gray-300" />
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />

<DropdownMenuGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
}[]
}

export function EditDviceSidebarNav({
export function EditDeviceSidebarNav({
className,
items,
...props
Expand Down
6 changes: 4 additions & 2 deletions app/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
>(({ className, children, hideClose, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
Expand All @@ -52,10 +52,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
{!hideClose &&(
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
Expand Down
2 changes: 2 additions & 0 deletions app/lib/request-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export async function parseUserRegistrationData(request: Request): Promise<{
email: string
password: string
language: string
tosAccepted: boolean
}> {
const data = await parseRequestData(request)

Expand All @@ -51,6 +52,7 @@ export async function parseUserRegistrationData(request: Request): Promise<{
email: data.email || '',
password: data.password || '',
language: data.language || 'en_US',
tosAccepted: data.tosAccepted || false
}
}

Expand Down
11 changes: 10 additions & 1 deletion app/lib/user-service.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ import {
type UsernameValidation,
validateEmail,
validatePassword,
validateTosAccepted,
validateUsername,
} from './user-service'
import { drizzleClient } from '~/db.server'
import { getCurrentEffectiveTos } from '~/models/tos.server'
import {
createUser,
deleteUserByEmail,
Expand Down Expand Up @@ -57,6 +59,7 @@ export const registerUser = async (
email: string,
password: string,
language: 'de_DE' | 'en_US',
tosAccepted: boolean,
): Promise<
UsernameValidation | EmailValidation | PasswordValidation | User | null
> => {
Expand All @@ -69,10 +72,16 @@ export const registerUser = async (
const passwordValidation = validatePassword(password)
if (!passwordValidation.isValid) return passwordValidation

const tosValidation = validateTosAccepted(tosAccepted)
if(!tosValidation.isValid) return tosValidation

const tos = await getCurrentEffectiveTos()
invariant(tos, 'Expected tos to be configured.')

const existingUser = await getUserByEmail(email)
if (existingUser) return null // no new user is created -> null

const newUsers = await createUser(username, email, language, password)
const newUsers = await createUser(username, email, language, password, tos.id)
if (newUsers.length === 0)
throw new Error('Something went wrong creating the user profile!')

Expand Down
16 changes: 15 additions & 1 deletion app/lib/user-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
type RegistrationInputValidation = {
validationKind: 'username' | 'email' | 'password'
validationKind: 'username' | 'email' | 'password' | 'tos'
}

export type UsernameValidation = {
Expand Down Expand Up @@ -65,3 +65,17 @@ export const validatePassword = (password: string): PasswordValidation => {
return { isValid: false, length: true, validationKind: 'password' }
return { isValid: true, validationKind: 'password' }
}

export type TosValidation = {
isValid: boolean
required?: boolean
} & RegistrationInputValidation

export const validateTosAccepted = (tosAccepted: boolean): TosValidation => {
if (!tosAccepted) {
return { isValid: false, required: true, validationKind: 'tos' }
}
return { isValid: true, validationKind: 'tos' }
}


68 changes: 68 additions & 0 deletions app/middleware/tos-api.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { getUserFromJwt } from "~/lib/jwt";
import { getTosRequirementForUser } from "~/models/tos.server";

function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type AllowRule = {
method: HttpMethod | '*'
pathname: string | RegExp
}

const API_TOS_ALLOWLIST: AllowRule[] = [
{ method: 'POST', pathname: '/api/users/refresh-auth' },
{ method: 'POST', pathname: '/api/users/sign-out' },

{ method: 'DELETE', pathname: '/api/users/me' },

{ method: 'POST', pathname: '/api/users/me/accept-tos' },
]

function isAllowedApi(request: Request, pathname: string) {
const method = request.method as HttpMethod

return API_TOS_ALLOWLIST.some((rule) => {
if (rule.method !== '*' && rule.method !== method) return false

if (rule.pathname instanceof RegExp) {
return rule.pathname.test(pathname)
}

return rule.pathname === pathname
})
}

export async function tosApiMiddleware(
{ request }: { request: Request },
next: () => Promise<Response>,
) {
const url = new URL(request.url);

const jwtUser = await getUserFromJwt(request);
if (typeof jwtUser !== "object") {
return next();
}

if (isAllowedApi(request, url.pathname)) {
return next();
}

const req = await getTosRequirementForUser(jwtUser.id);
if (req.required && req.tos) {
return json(
{
code: "tos_required",
tosVersionId: req.tos.id,
effectiveFrom: req.tos.effectiveFrom,
},
428,
);
}

return next();
}
41 changes: 41 additions & 0 deletions app/middleware/tos-ui.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { redirect } from "react-router";
import { getTosRequirementForUser } from "~/models/tos.server";
import { getUserId } from "~/utils/session.server";

function isAllowedUiPath(pathname: string) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason why we couldn't handle both ui and server in one middleware?
They seem very related to each other and technically we are always handling just "requests", right?

I do understand the response types are different, but this way there also seems to be quite a bit of duplication?
Maybe I am just too picky though :D

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes i thought about it as well, i initially thought that maybe in a single file it could get a bit messy with potentially more branches over time, but thinking about it again i guess this would still be quite manageable. In terms of code duplication i think the way it is at the moment is still somewhat justifiable, but i think i could still add a small helper to further reduce it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am also thinking a bit about "mental load" here. People have to remember to put the right middleware for the right kind of route. It's not like its hugely complicated though, so keeping it separate is probably perfectly fine.

Having more time comparing the two, I am realizing where the differences are. It does make it more complicated to unify the two, so overall we would be trading that for complexity of the code.
I'd rather create a base class/ function for each type of route to make sure the right middleware is called automatically and people don't have to bear the mental load of differentiating ;-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The right middleware should already be called automatically due to where they are attached (ui middlware in root.tsx and api in routes.api). Is this what you meant?

if (pathname.startsWith("/explore")) return true;
if (pathname === "/terms") return true;
if (pathname === "/settings/delete") return true;
if (pathname === "/logout") return true;
if (pathname === '/tos-required') return true;
if (pathname.startsWith("/profile")) return true;

return false;
}

export async function tosUiMiddleware(
{ request }: { request: Request },
next: () => Promise<Response>,
) {
const url = new URL(request.url);

if (url.pathname.startsWith("/api")) {
// handled by tos-api middleware
return next();
}

if (isAllowedUiPath(url.pathname)) {
return next();
}

const userId = await getUserId(request);
if (!userId) return next();

const req = await getTosRequirementForUser(userId);
if (req.required && req.tos) {
const redirectTo = url.pathname + url.search;
throw redirect(`/tos-required?redirectTo=${encodeURIComponent(redirectTo)}`)
}

return next();
}
22 changes: 22 additions & 0 deletions app/models/device.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,28 @@ export function getDevice({ id }: Pick<Device, 'id'>) {
})
}

export function getUserDevice({
id,
userId,
}: Pick<Device, 'id' | 'userId'>) {
return drizzleClient.query.device.findFirst({
where: (d, { and, eq }) => and(eq(d.id, id), eq(d.userId, userId)),
columns: {
id: true,
name: true,
description: true,
exposure: true,
image: true,
tags: true,
website: true,
updatedAt: true,
latitude: true,
longitude: true,
userId: true,
},
})
}

export function getLocations(
{ id }: Pick<Device, 'id'>,
fromDate: Date,
Expand Down
96 changes: 96 additions & 0 deletions app/models/tos.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { drizzleClient } from '~/db.server'
import { tosUserState } from '~/schema/tos'

export const ONE_DAY_MS = 24 * 60 * 60 * 1000
export const TOS_GRACE_DAYS = 7

export async function getCurrentEffectiveTos(now = new Date()) {
return drizzleClient.query.tosVersion.findFirst({
where: (t, { lte }) => lte(t.effectiveFrom, now),
orderBy: (t, { desc }) => [desc(t.effectiveFrom)],
})
}

async function getUserTosState(userId: string, tosVersionId: string) {
return drizzleClient.query.tosUserState.findFirst({
where: (s, { and, eq }) => and(eq(s.userId, userId), eq(s.tosVersionId, tosVersionId)),
})
}

async function ensureUserTosState({
userId,
tos,
now = new Date(),
}: {
userId: string
tos: { id: string; graceDays?: number | null }
now?: Date
}) {
const existing = await getUserTosState(userId, tos.id)
if (existing) return existing

const graceDays = tos.graceDays ?? TOS_GRACE_DAYS
const graceUntil = new Date(now.getTime() + graceDays * ONE_DAY_MS)

const inserted = await drizzleClient
.insert(tosUserState)
.values({
userId,
tosVersionId: tos.id,
firstSeenAt: now,
graceUntil,
})
.onConflictDoNothing()
.returning()

return inserted[0] ?? (await getUserTosState(userId, tos.id))
}

export async function markTosAccepted({
userId,
tosId,
now = new Date(),
graceDays = TOS_GRACE_DAYS,
}: {
userId: string
tosId: string
now?: Date
graceDays?: number
}) {
const graceUntil = new Date(now.getTime() + graceDays * ONE_DAY_MS)

await drizzleClient
.insert(tosUserState)
.values({
userId,
tosVersionId: tosId,
firstSeenAt: now,
graceUntil,
acceptedAt: now,
})
.onConflictDoUpdate({
target: [tosUserState.userId, tosUserState.tosVersionId],
set: { acceptedAt: now },
})
}

export async function getTosRequirementForUser(userId: string, now = new Date()) {
const current = await getCurrentEffectiveTos(now)
if (!current) {
return { tos: null, accepted: true, inGrace: false, mustBlock: false, graceUntil: null }
}

const state = await ensureUserTosState({
userId,
tos: { id: current.id, graceDays: (current as any).graceDays },
now,
})

const accepted = !!state?.acceptedAt
const graceUntil = state?.graceUntil ?? null
const inGrace = !accepted && !!graceUntil && now <= new Date(graceUntil)

const mustBlock = !accepted && !inGrace

return { tos: current, accepted, inGrace, mustBlock, graceUntil }
}
Loading