From df9ddc96909da26799f774c530bf39a33c8ce44b Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Feb 2026 16:41:13 +0100 Subject: [PATCH 1/8] feat: add and use callout component for device security page --- app/components/ui/alert.tsx | 175 ++++-- app/routes/device.$deviceId.edit.security.tsx | 260 +++++---- app/styles/app.css | 21 +- public/locales/de/settings.json | 16 +- public/locales/de/ui-components.json | 9 + public/locales/en/settings.json | 116 ++-- public/locales/en/ui-components.json | 9 + tailwind.config.ts | 501 +++++++++--------- 8 files changed, 613 insertions(+), 494 deletions(-) create mode 100644 public/locales/de/ui-components.json create mode 100644 public/locales/en/ui-components.json diff --git a/app/components/ui/alert.tsx b/app/components/ui/alert.tsx index d9a5d137..7268117d 100644 --- a/app/components/ui/alert.tsx +++ b/app/components/ui/alert.tsx @@ -1,60 +1,137 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; - -import { cn } from "@/lib/utils"; +import { cva, type VariantProps } from 'class-variance-authority' +import { + type LucideIcon, + LucideInfo, + LucideLightbulb, + LucideMessageCircleWarning, + LucideTriangleAlert, + LucideOctagonAlert, +} from 'lucide-react' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' const alertVariants = cva( - "relative w-full rounded-lg border border-slate-200 p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 dark:border-slate-800 dark:[&>svg]:text-slate-50", - { - variants: { - variant: { - default: - "bg-white text-slate-950 dark:bg-dark-boxes dark:text-dark-text ", - destructive: - "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); + 'relative w-full rounded-md border border-slate-200 p-4 dark:border-slate-800 [&:has(svg)]:pl-12 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 dark:[&>svg]:text-slate-50', + { + variants: { + variant: { + default: + 'bg-white text-slate-950 dark:bg-dark-boxes dark:text-dark-text', + destructive: + 'border-red-500/50 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900 text-red-500 dark:border-red-500 [&>svg]:text-red-500', + note: 'border-blue-500/50 dark:border-blue-900/50 dark:bg-blue-900/20 bg-blue-50 dark:text-blue-200 dark:[&>svg]:text-blue-400 text-blue-900 [&>svg]:text-blue-500', + tip: 'border-green-500/50 dark:border-green-900/50 dark:bg-green-900/20 bg-green-50 text-green-900 dark:text-green-200 [&>svg]:text-green-500 dark:[&>svg]:text-green-400', + important: + 'border-violet-500/50 bg-violet-50 text-violet-900 dark:border-violet-900/50 dark:bg-violet-900/20 dark:text-violet-200 dark:[&>svg]:text-violet-400 [&>svg]:text-violet-500', + warning: + 'border-yellow-500/50 bg-yellow-50 text-yellow-900 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-200 [&>svg]:text-yellow-500 dark:[&>svg]:text-yellow-400', + caution: + 'border-red-500/50 dark:border-red-900/50 dark:bg-red-900/20 bg-red-50 text-red-900 dark:text-red-200 dark:[&>svg]:text-red-400 [&>svg]:text-red-500', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps + HTMLDivElement, + React.HTMLAttributes & VariantProps >(({ className, variant, ...props }, ref) => ( -
-)); -Alert.displayName = "Alert"; +
+)) +Alert.displayName = 'Alert' const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes + HTMLParagraphElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)); -AlertTitle.displayName = "AlertTitle"; +
+)) +AlertTitle.displayName = 'AlertTitle' const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes + HTMLParagraphElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)); -AlertDescription.displayName = "AlertDescription"; - -export { Alert, AlertTitle, AlertDescription }; +
+)) +AlertDescription.displayName = 'AlertDescription' + +interface CalloutProps { + variant: Exclude< + VariantProps['variant'], + 'default' | 'destructive' | undefined | null + > +} + +const VARIANT_MAPPING: { + [key in CalloutProps['variant']]: { + icon: LucideIcon + translationResource: string + } +} = { + note: { + icon: LucideInfo, + translationResource: 'callout_title_note', + }, + tip: { + icon: LucideLightbulb, + translationResource: 'callout_title_tip', + }, + important: { + icon: LucideMessageCircleWarning, + translationResource: 'callout_title_important', + }, + warning: { + icon: LucideTriangleAlert, + translationResource: 'callout_title_warning', + }, + caution: { + icon: LucideOctagonAlert, + translationResource: 'callout_title_caution', + }, +} + +/** + * A convenience wrapper for {@link Alert} that predefines icons + * and titles for different types of callouts (notes, tips, warnings, etc.) + */ +const Callout = ( + props: React.PropsWithChildren = { + variant: 'note', + }, +) => { + const { t } = useTranslation('ui-components') + const map = VARIANT_MAPPING[props.variant] + const Icon = map.icon + const title = t(`callout.${map.translationResource}`) + + return ( + + + {title} + {props.children} + + ) +} + +export { Alert, AlertTitle, AlertDescription, Callout } diff --git a/app/routes/device.$deviceId.edit.security.tsx b/app/routes/device.$deviceId.edit.security.tsx index 973fe060..a7dfd49d 100644 --- a/app/routes/device.$deviceId.edit.security.tsx +++ b/app/routes/device.$deviceId.edit.security.tsx @@ -1,162 +1,140 @@ -import { RefreshCw, Save } from "lucide-react"; -import { useState } from "react"; -import { type LoaderFunctionArgs, redirect , Form } from "react-router"; -import { Checkbox } from "@/components/ui/checkbox"; -import ErrorMessage from "~/components/error-message"; -import { getUserId } from "~/utils/session.server"; +import { Label } from '@radix-ui/react-label' +import { + LucideCopy, + LucideEye, + LucideEyeOff, + RefreshCw, + Save, +} from 'lucide-react' +import { useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { type LoaderFunctionArgs, redirect, Form } from 'react-router' +import { Checkbox } from '@/components/ui/checkbox' +import ErrorMessage from '~/components/error-message' +import { Callout } from '~/components/ui/alert' +import { getUserId } from '~/utils/session.server' -//***************************************************** export async function loader({ request }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request); - if (!userId) return redirect("/"); + //* if user is not logged in, redirect to home + const userId = await getUserId(request) + if (!userId) return redirect('/') - return ""; + return '' } -//***************************************************** export async function action() { - return ""; + return '' } -//********************************** export default function EditBoxSecurity() { - const [tokenVisibility, setTokenvisibility] = useState(false); + const { t } = useTranslation('settings') + const [keyVisible, setTokenvisibility] = useState(false) - return ( - (
-
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

Change security settings

-
-
- {/* Save button */} - -
-
-
+ const copyKeyToClipboard = () => {} - {/* divider */} -
+ return ( + +
+

{t('device_security.page_title')}

+ +
-
-

- DANGER: If you deactivate this option everyone can send data to - your senseBox. -

-
+
-
- - -
+

+ + Devices should use their API key shown on this page to authenticate + requests sent to the openSenseMap API. This ensures that only + authenticated devices update the state of the device on openSenseMap. + The API key is appended to every request made to the API. More + information can be found{' '} + + in the docs + + . + +

- {/* Access Token */} -
- -
- - - - -
-
+ + {t('device_security.warning_deactive_auth')} + -
-

- DANGER: If you generate a new token you have to upload a new - sketch to your senseBox. This step can not be undone. -

-
+
+ + +
- -
+
+ +
+ + + + + + + +
+
+
-
-

- Further information regarding security on openSenseMap can be - found here (also for TTN and MQTT): - - https://en.docs.sensebox.de/opensensemap/opensensemap-security/ - -

-
-
-
-
-
) - ); + +

+ + Generating a new key will require you to update your device (e.g. + change the sketch/ code). + This step can not be undone. + +

+ +
+ + ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/styles/app.css b/app/styles/app.css index 2dfd8e15..245ca5b4 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -39,11 +39,12 @@ --color-zinc-700: #3f3f46; --color-zinc-800: #27272a; --color-zinc-900: #18181b; + --color-blue-50: #eff6ff; --color-blue-100: #3d9cce; --color-blue-200: #1879af; + --color-blue-400: #60a5fa; --color-blue-500: #3b82f6; --color-blue-700: #1d4ed8; - --color-blue-50: #f8fafc; --color-gray-50: #f9fafb; --color-gray-100: #f3f4f6; --color-gray-200: #e5e7eb; @@ -66,11 +67,25 @@ --color-green-800: #166534; --color-green-900: #14532d; --color-green-950: #052e16; - --color-red-500: #f10000; + --color-red-50: #fef2f2; + --color-red-100: #fee2e2; + --color-red-200: #fecaca; + --color-red-400: #f87171; + --color-red-500: #ef4444; --color-red-700: #b91c1c; --color-orange-500: #f97316; - --color-violet-500: #7f00ff; + --color-violet-50: #f5f3ff; + --color-violet-100: #ede9fe; + --color-violet-200: #ddd6fe; + --color-violet-400: #a78bfa; + --color-violet-500: #8b5cf6; + --color-violet-700: #6d28d9; + --color-yellow-50: #fefce8; + --color-yellow-100: #fef9c3; + --color-yellow-200: #fef08a; + --color-yellow-400: #facc15; --color-yellow-500: #eab308; + --color-yellow-700: #a16207; --color-yellow-sensorWiki: #ffdd57; --color-headerBorder: #e3e3e3; diff --git a/public/locales/de/settings.json b/public/locales/de/settings.json index 05990253..d3ecacff 100644 --- a/public/locales/de/settings.json +++ b/public/locales/de/settings.json @@ -3,7 +3,6 @@ "account": "Konto", "password": "Passwort", "delete_account": "Konto löschen", - "profile_updated": "Profil aktualisiert", "profile_updated_description": "Dein Profil wurde erfolgreich aktualisiert.", "something_went_wrong": "Etwas ist schief gelaufen.", @@ -18,12 +17,10 @@ "if_activated_public_3": " sehen können.", "change_profile_photo": "Profilbild ändern", "save_changes": "Änderungen speichern", - "profile_photo": "Profilbild", "save_photo": "Profilbild speichern", "reset": "Zurücksetzen", "change": "Ändern", - "invalid_password": "Falsches Passwort", "profile_successfully_updated": "Profil efolgreich aktualisiert", "account_information": "Konto Informationen", @@ -36,7 +33,6 @@ "select_language": "Wähle die Sprache aus", "confirm_password": "Bestätige dein Passwort", "enter_current_password": "Gib dein aktuelles Passwort ein", - "try_again": "Versuche es nochmal.", "update_password": "Aktualisiere dein Passwort", "update_password_description": "Gib dein aktuelles und ein neues Passwort ein, um dein Konto Passwort zu aktualisieren.", @@ -51,7 +47,13 @@ "new_passwords_do_not_match": "Die neuen Passwörter stimmen nicht überein.", "current_password_incorret": "Das aktuelle Passwort ist falsch.", "password_updated_successfully": "Das Passwort wurde erfolgreich aktualisiert.", - "delete_account_description": "Wenn du dein Konto löschst, werden alle deine Daten permanent von unseren Servern gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", - "enter_password": "Gib dein Passwort ein" -} \ No newline at end of file + "enter_password": "Gib dein Passwort ein", + "device_security": { + "warning_deactive_auth": "Wenn die Gerätesicherheit deaktiviert ist, kann jede Person Messdaten senden, die Ihrem Gerät zugewiesen sind!", + "page_title": "Sicherheitseinstellungen ändern", + "api_key_label": "API Schlüssel", + "explanation_text": "Geräte sollten stets ihren API Schlüssel nutzen, um ihre Anfragen an die openSenseMap API zu authentifizieren. Dies stellt sicher, dass nur authentifizierte Geräte den Zustand des Gerätes auf der openSenseMap aktualisieren können. Der API Schlüssel wird an jede Anfrage an die API angehängt. Mehr Informationen <2>gibt es in der Dokumentation.", + "generate_new_key_warning": "Das Generieren eines neuen Schlüssel zieht zwangsläufig ein Update des Gerätes nach sich (z.B. Ändern des Codes/ Sketches). <1>Dieser Schritt kann nicht rückgängig gemacht werden." + } +} diff --git a/public/locales/de/ui-components.json b/public/locales/de/ui-components.json new file mode 100644 index 00000000..10f57331 --- /dev/null +++ b/public/locales/de/ui-components.json @@ -0,0 +1,9 @@ +{ + "callout": { + "callout_title_note": "Hinweis", + "callout_title_tip": "Tipp", + "callout_title_important": "Wichtig", + "callout_title_warning": "Warnung", + "callout_title_caution": "Achtung" + } +} diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index f8a3841f..9f6935ac 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -1,57 +1,61 @@ { - "public_profile": "Public Profile", - "account": "Account", - "password": "Password", - "delete_account": "Delete Account", - - "profile_updated": "Profile updated", - "profile_updated_description": "Your profile has been updated successfully.", - "something_went_wrong": "Something went wrong.", - "something_went_wrong_description": "Please try again later.", - "profile_settings": "Profile Settings", - "profile_settings_description": "This is how others see your profile.", - "username": "Username", - "if_public": "If your profile is public, this is how people will see you.", - "enter_username": "Enter your new username", - "if_activated_public_1": "If activated, others will be able to see your public", - "if_activated_public_2": "profile", - "if_activated_public_3": ".", - "change_profile_photo": "Change profile photo", - "save_changes": "Save changes", - - "profile_photo": "Profile photo", - "save_photo": "Save Photo", - "reset": "Reset", - "change": "Change", - - "invalid_password": "Invalid password", - "profile_successfully_updated": "Profile successfully updated.", - "account_information": "Account Information", - "update_basic_details": "Update your basic account details.", - "name": "Name", - "enter_name": "Enter your name", - "email": "Email", - "enter_email": "Enter your email", - "language": "Language", - "select_language": "Select language", - "confirm_password": "Confirm password", - "enter_current_password": "Enter your current password", - - "try_again": "Please try again.", - "update_password": "Update Password", - "update_password_description": "Enter your current password and a new password to update your account password.", - "current_password": "Current Password", - "new_password": "New Password", - "enter_new_password": "Enter your new password", - "confirm_new_password": "Confirm your new password", - "password_required": "Password is required.", - "password_length": "Password must be at least 8 characters long.", - "email_not_found": "Email not found!", - "current_password_required": "Current password is required.", - "new_passwords_do_not_match": "New passwords do not match.", - "current_password_incorret": "Current password is incorrect.", - "password_updated_successfully": "Password updated successfully.", - - "delete_account_description": "Deleting your account will permanently remove all of your data from our servers. This action cannot be undone.", - "enter_password": "Enter your password" -} \ No newline at end of file + "public_profile": "Public Profile", + "account": "Account", + "password": "Password", + "delete_account": "Delete Account", + "profile_updated": "Profile updated", + "profile_updated_description": "Your profile has been updated successfully.", + "something_went_wrong": "Something went wrong.", + "something_went_wrong_description": "Please try again later.", + "profile_settings": "Profile Settings", + "profile_settings_description": "This is how others see your profile.", + "username": "Username", + "if_public": "If your profile is public, this is how people will see you.", + "enter_username": "Enter your new username", + "if_activated_public_1": "If activated, others will be able to see your public", + "if_activated_public_2": "profile", + "if_activated_public_3": ".", + "change_profile_photo": "Change profile photo", + "save_changes": "Save changes", + "profile_photo": "Profile photo", + "save_photo": "Save Photo", + "reset": "Reset", + "change": "Change", + "invalid_password": "Invalid password", + "profile_successfully_updated": "Profile successfully updated.", + "account_information": "Account Information", + "update_basic_details": "Update your basic account details.", + "name": "Name", + "enter_name": "Enter your name", + "email": "Email", + "enter_email": "Enter your email", + "language": "Language", + "select_language": "Select language", + "confirm_password": "Confirm password", + "enter_current_password": "Enter your current password", + "try_again": "Please try again.", + "update_password": "Update Password", + "update_password_description": "Enter your current password and a new password to update your account password.", + "current_password": "Current Password", + "new_password": "New Password", + "enter_new_password": "Enter your new password", + "confirm_new_password": "Confirm your new password", + "password_required": "Password is required.", + "password_length": "Password must be at least 8 characters long.", + "email_not_found": "Email not found!", + "current_password_required": "Current password is required.", + "new_passwords_do_not_match": "New passwords do not match.", + "current_password_incorret": "Current password is incorrect.", + "password_updated_successfully": "Password updated successfully.", + "delete_account_description": "Deleting your account will permanently remove all of your data from our servers. This action cannot be undone.", + "enter_password": "Enter your password", + "device_security": { + "warning_deactive_auth": "If you disable device security, anyone can send measurement data that is assigned to your device!", + "page_title": "Change security settings", + "auth_enable_checkbox_label": "Enable authentication", + "api_key_label": "API Key", + "explanation_text": "Devices should use their API key shown on this page to authenticate requests sent to the openSenseMap API. This ensures that only authenticated devices update the state of the device on openSenseMap. The API key is appended to every request made to the API. More information can be found <2>in the docs.", + "generate_new_key_button": "Generate a new key", + "generate_new_key_warning": "Generating a new key will require you to update your device (e.g. change the sketch/ code). <1>This step can not be undone." + } +} diff --git a/public/locales/en/ui-components.json b/public/locales/en/ui-components.json new file mode 100644 index 00000000..2177d5bb --- /dev/null +++ b/public/locales/en/ui-components.json @@ -0,0 +1,9 @@ +{ + "callout": { + "callout_title_note": "Note", + "callout_title_tip": "Tip", + "callout_title_important": "Important", + "callout_title_warning": "Warning", + "callout_title_caution": "Caution" + } +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 5a51b53f..fbe2bde3 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,241 +1,266 @@ -import tailwindTypography from "@tailwindcss/typography"; -import { type Config } from "tailwindcss"; -import defaultTheme from "tailwindcss/defaultTheme"; -import tailwindcssAnimate from "tailwindcss-animate"; +import tailwindTypography from '@tailwindcss/typography' +import { type Config } from 'tailwindcss' +import defaultTheme from 'tailwindcss/defaultTheme' +import tailwindcssAnimate from 'tailwindcss-animate' export default { - darkMode: ["class"], - content: ["./app/**/*.{ts,tsx,js,jsx}"], - theme: { - // shadcn container - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - colors: { - // Color schem is defined in /styles/app.css - transparent: "transparent", - current: "currentColor", - white: "var(--color-white)", - black: "var(--color-black)", - gray: { - 50: "var(--color-gray-50)", - 100: "var(--color-gray-100)", - 200: "var(--color-gray-200)", - 300: "var(--color-gray-300)", - 400: "var(--color-gray-400)", - 500: "var(--color-gray-500)", - 600: "var(--color-gray-600)", - 700: "var(--color-gray-700)", - 800: "var(--color-gray-800)", - 900: "var(--color-gray-900)", - 950: "var(--color-gray-950)", - }, - zinc: { - 50: "var(--color-zinc-50)", - 100: "var(--color-zinc-100)", - 200: "var(--color-zinc-200)", - 300: "var(--color-zinc-300)", - 400: "var(--color-zinc-400)", - 500: "var(--color-zinc-500)", - 600: "var(--color-zinc-600)", - 700: "var(--color-zinc-700)", - 800: "var(--color-zinc-800)", - 900: "var(--color-zinc-900)", - }, - slate: { - 50: "#f8fafc", - 100: "#f1f5f9", - 200: "#e2e8f0", - 300: "#cbd5e1", - 400: "#94a3b8", - 500: "#64748b", - 600: "#475569", - 700: "#334155", - 800: "#1e293b", - 900: "#0f172a", - 950: "#020617", - }, - green: { - 50: "var(--color-green-50)", - 100: "var(--color-green-100)", - 200: "var(--color-green-200)", - 300: "var(--color-green-300)", - 400: "var(--color-green-400)", - 500: "var(--color-green-500)", - 700: "var(--color-green-700)", - 900: "var(--color-green-900)", - 950: "var(--color-green-950)", - }, - blue: { - 100: "var(--color-blue-100)", - 300: "var(--color-blue-300)", - 500: "var(--color-blue-500)", - 700: "var(--color-blue-700)", - 900: "var(--color-blue-900)", - }, - red: { - 500: "var(--color-red-500)", - 700: "var(--color-red-700)", - }, - orange: { - 500: "var(--color-orange-500)", - }, - violet: { - 500: "var(--color-violet-500)", - }, - yellow: "#FFD700", - sensorWiki: "var(--color-yellow-sensorWiki)", - headerBorder: "var(--color-headerBorder)", - logo: { - green: "#4fae48", - blue: "#00b4e4", - }, - light: { - menu: "#727373", - text: "#363636", - green: "#3D843F", - blue: "#037EA1", - }, - dark: { - title: "FFFFFF", - menu: "#D2D1D0", - text: "#D2D1D0", - green: "#6FA161", - blue: "#0386AA", - background: "#242424", - boxes: "#3B3A3A", - }, - }, - extend: { - colors: { - //osem color scheme - logo: { - green: "#4fae48", - blue: "#00b4e4", - }, - light: { - menu: "#727373", - text: "#363636", - green: "#3D843F", - blue: "#037EA1", - }, - dark: { - menu: "#D2D1D0", - text: "#D2D1D0", - green: "#6FA161", - blue: "#0386AA", - background: "#242424", - boxes: "#3B3A3A", - }, - // shadcn colors - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - // shadcn brder radius - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - fontFamily: { - sans: ["Urbanist", ...defaultTheme.fontFamily.sans], - serif: ["RobotoSlab", ...defaultTheme.fontFamily.serif], - monospace: ["Courier New", "Courier", "monospace"], - helvetica: ["Helvetica", "Arial", "sans-serif"], - }, - keyframes: { - sidebarOpen: { - from: { transform: "translateX(100%)" }, - to: { transform: "translateX(O)" }, - }, - sidebarClose: { - from: { transform: "translateX(0)" }, - to: { transform: "translateX(100%)" }, - }, - contentShow: { - from: { opacity: "0", transform: "translate(-50%, 0%) scale(0.5)" }, - to: { opacity: "1", transform: "translate(-50%, 0%) scale(1)" }, - }, - contentClose: { - from: { opacity: "1", transform: "translate(-50%, -50%) scale(1)" }, - to: { opacity: "0", transform: "translate(-50%, -48%) scale(0.5)" }, - }, - "fade-in-up": { - "0%": { - opacity: "0", - transform: "translateY(10px)", - }, - "100%": { - opacity: "1", - transform: "translateY(0)", - }, - }, - pulse: { - "0%, 100%": { - opacity: "1", - }, - "50%": { - opacity: ".5", - }, - }, + darkMode: ['class'], + content: ['./app/**/*.{ts,tsx,js,jsx}'], + theme: { + // shadcn container + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px', + }, + }, + colors: { + // Color schem is defined in /styles/app.css + transparent: 'transparent', + current: 'currentColor', + white: 'var(--color-white)', + black: 'var(--color-black)', + gray: { + 50: 'var(--color-gray-50)', + 100: 'var(--color-gray-100)', + 200: 'var(--color-gray-200)', + 300: 'var(--color-gray-300)', + 400: 'var(--color-gray-400)', + 500: 'var(--color-gray-500)', + 600: 'var(--color-gray-600)', + 700: 'var(--color-gray-700)', + 800: 'var(--color-gray-800)', + 900: 'var(--color-gray-900)', + 950: 'var(--color-gray-950)', + }, + zinc: { + 50: 'var(--color-zinc-50)', + 100: 'var(--color-zinc-100)', + 200: 'var(--color-zinc-200)', + 300: 'var(--color-zinc-300)', + 400: 'var(--color-zinc-400)', + 500: 'var(--color-zinc-500)', + 600: 'var(--color-zinc-600)', + 700: 'var(--color-zinc-700)', + 800: 'var(--color-zinc-800)', + 900: 'var(--color-zinc-900)', + }, + slate: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', + }, + green: { + 50: 'var(--color-green-50)', + 100: 'var(--color-green-100)', + 200: 'var(--color-green-200)', + 300: 'var(--color-green-300)', + 400: 'var(--color-green-400)', + 500: 'var(--color-green-500)', + 700: 'var(--color-green-700)', + 900: 'var(--color-green-900)', + 950: 'var(--color-green-950)', + }, + blue: { + 50: 'var(--color-blue-50)', + 100: 'var(--color-blue-100)', + 200: 'var(--color-blue-200)', + 300: 'var(--color-blue-300)', + 400: 'var(--color-blue-400)', + 500: 'var(--color-blue-500)', + 700: 'var(--color-blue-700)', + 900: 'var(--color-blue-900)', + }, + red: { + 50: 'var(--color-red-50)', + 100: 'var(--color-red-100)', + 200: 'var(--color-red-200)', + 300: 'var(--color-red-300)', + 400: 'var(--color-red-400)', + 500: 'var(--color-red-500)', + 700: 'var(--color-red-700)', + 900: 'var(--color-red-900)', + }, + orange: { + 500: 'var(--color-orange-500)', + }, + violet: { + 50: 'var(--color-violet-50)', + 100: 'var(--color-violet-100)', + 200: 'var(--color-violet-200)', + 300: 'var(--color-violet-300)', + 400: 'var(--color-violet-400)', + 500: 'var(--color-violet-500)', + 700: 'var(--color-violet-700)', + 900: 'var(--color-violet-900)', + }, + yellow: { + 50: 'var(--color-yellow-50)', + 100: 'var(--color-yellow-100)', + 200: 'var(--color-yellow-200)', + 300: 'var(--color-yellow-300)', + 400: 'var(--color-yellow-400)', + 500: 'var(--color-yellow-500)', + 700: 'var(--color-yellow-700)', + 900: 'var(--color-yellow-900)', + }, + sensorWiki: 'var(--color-yellow-sensorWiki)', + headerBorder: 'var(--color-headerBorder)', + logo: { + green: '#4fae48', + blue: '#00b4e4', + }, + light: { + menu: '#727373', + text: '#363636', + green: '#3D843F', + blue: '#037EA1', + }, + dark: { + title: 'FFFFFF', + menu: '#D2D1D0', + text: '#D2D1D0', + green: '#6FA161', + blue: '#0386AA', + background: '#242424', + boxes: '#3B3A3A', + }, + }, + extend: { + colors: { + //osem color scheme + logo: { + green: '#4fae48', + blue: '#00b4e4', + }, + light: { + menu: '#727373', + text: '#363636', + green: '#3D843F', + blue: '#037EA1', + }, + dark: { + menu: '#D2D1D0', + text: '#D2D1D0', + green: '#6FA161', + blue: '#0386AA', + background: '#242424', + boxes: '#3B3A3A', + }, + // shadcn colors + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + // shadcn brder radius + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + fontFamily: { + sans: ['Urbanist', ...defaultTheme.fontFamily.sans], + serif: ['RobotoSlab', ...defaultTheme.fontFamily.serif], + monospace: ['Courier New', 'Courier', 'monospace'], + helvetica: ['Helvetica', 'Arial', 'sans-serif'], + }, + keyframes: { + sidebarOpen: { + from: { transform: 'translateX(100%)' }, + to: { transform: 'translateX(O)' }, + }, + sidebarClose: { + from: { transform: 'translateX(0)' }, + to: { transform: 'translateX(100%)' }, + }, + contentShow: { + from: { opacity: '0', transform: 'translate(-50%, 0%) scale(0.5)' }, + to: { opacity: '1', transform: 'translate(-50%, 0%) scale(1)' }, + }, + contentClose: { + from: { opacity: '1', transform: 'translate(-50%, -50%) scale(1)' }, + to: { opacity: '0', transform: 'translate(-50%, -48%) scale(0.5)' }, + }, + 'fade-in-up': { + '0%': { + opacity: '0', + transform: 'translateY(10px)', + }, + '100%': { + opacity: '1', + transform: 'translateY(0)', + }, + }, + pulse: { + '0%, 100%': { + opacity: '1', + }, + '50%': { + opacity: '.5', + }, + }, - // shadcn accordion animation - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - }, - animation: { - "fade-in-up": "fade-in-up 1s ease-out", - sidebarOpen: "sidebarOpen 300ms ease-out", - sidebarClose: "sidebarClose 300ms ease-out", - contentShow: "contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)", - contentClose: "contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)", - pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", - // shadcn accordion animation - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, - }, - plugins: [tailwindcssAnimate, tailwindTypography], -} satisfies Config; + // shadcn accordion animation + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'fade-in-up': 'fade-in-up 1s ease-out', + sidebarOpen: 'sidebarOpen 300ms ease-out', + sidebarClose: 'sidebarClose 300ms ease-out', + contentShow: 'contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)', + contentClose: 'contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)', + pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + // shadcn accordion animation + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [tailwindcssAnimate, tailwindTypography], +} satisfies Config From d267cb95f9df53a03183a23d76c171764b8fbdef Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Feb 2026 16:44:28 +0100 Subject: [PATCH 2/8] feat: stub write to clipboard --- app/routes/device.$deviceId.edit.security.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/routes/device.$deviceId.edit.security.tsx b/app/routes/device.$deviceId.edit.security.tsx index a7dfd49d..128f35a4 100644 --- a/app/routes/device.$deviceId.edit.security.tsx +++ b/app/routes/device.$deviceId.edit.security.tsx @@ -30,7 +30,10 @@ export default function EditBoxSecurity() { const { t } = useTranslation('settings') const [keyVisible, setTokenvisibility] = useState(false) - const copyKeyToClipboard = () => {} + const copyKeyToClipboard = async () => { + const key = 'dummy token' + await navigator.clipboard.writeText(key) + } return (
From 21631f8c8c058c181a56dd07b4aed0cde389186d Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Wed, 4 Feb 2026 16:57:28 +0100 Subject: [PATCH 3/8] feat: load useAuth and key from server --- app/routes/device.$deviceId.edit.security.tsx | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/app/routes/device.$deviceId.edit.security.tsx b/app/routes/device.$deviceId.edit.security.tsx index 128f35a4..b9e96370 100644 --- a/app/routes/device.$deviceId.edit.security.tsx +++ b/app/routes/device.$deviceId.edit.security.tsx @@ -8,31 +8,44 @@ import { } from 'lucide-react' import { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { type LoaderFunctionArgs, redirect, Form } from 'react-router' +import { + type LoaderFunctionArgs, + redirect, + Form, + useLoaderData, +} from 'react-router' import { Checkbox } from '@/components/ui/checkbox' import ErrorMessage from '~/components/error-message' import { Callout } from '~/components/ui/alert' +import { findAccessToken, getDevice } from '~/models/device.server' import { getUserId } from '~/utils/session.server' -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ request, params }: LoaderFunctionArgs) { //* if user is not logged in, redirect to home const userId = await getUserId(request) if (!userId) return redirect('/') - return '' + const deviceId = params.deviceId + if (typeof deviceId !== 'string') throw 'deviceID not found' + + const t = await findAccessToken(deviceId) + const device = await getDevice({ id: deviceId }) + return { key: t?.token, deviceAuthEnabled: device?.useAuth ?? false } } export async function action() { + console.log('hello world') return '' } export default function EditBoxSecurity() { const { t } = useTranslation('settings') + const { key, deviceAuthEnabled } = useLoaderData() const [keyVisible, setTokenvisibility] = useState(false) + const [authEnabled, setAuthEnabled] = useState(deviceAuthEnabled) const copyKeyToClipboard = async () => { - const key = 'dummy token' - await navigator.clipboard.writeText(key) + if (key) await navigator.clipboard.writeText(key) } return ( @@ -72,7 +85,11 @@ export default function EditBoxSecurity() {
- + setAuthEnabled(!authEnabled)} + /> @@ -87,6 +104,7 @@ export default function EditBoxSecurity() { From 19c9ed6bf890797d5f772334c057a1bc12d2a635 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Thu, 5 Feb 2026 10:23:29 +0100 Subject: [PATCH 4/8] feat: sketch post and put for generating new token and updating status --- app/routes/device.$deviceId.edit.security.tsx | 204 +++++++++--------- 1 file changed, 108 insertions(+), 96 deletions(-) diff --git a/app/routes/device.$deviceId.edit.security.tsx b/app/routes/device.$deviceId.edit.security.tsx index b9e96370..ce473d31 100644 --- a/app/routes/device.$deviceId.edit.security.tsx +++ b/app/routes/device.$deviceId.edit.security.tsx @@ -13,6 +13,7 @@ import { redirect, Form, useLoaderData, + type ActionFunctionArgs, } from 'react-router' import { Checkbox } from '@/components/ui/checkbox' import ErrorMessage from '~/components/error-message' @@ -33,8 +34,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return { key: t?.token, deviceAuthEnabled: device?.useAuth ?? false } } -export async function action() { - console.log('hello world') +export async function action({ request }: ActionFunctionArgs) { + switch (request.method) { + case 'POST': + break + case 'PUT': + break + } + + const formData = await request.formData() + console.log(formData) + return '' } @@ -49,107 +59,109 @@ export default function EditBoxSecurity() { } return ( - -
-

{t('device_security.page_title')}

- -
- -
+
+ +
+

{t('device_security.page_title')}

+ +
-

- - Devices should use their API key shown on this page to authenticate - requests sent to the openSenseMap API. This ensures that only - authenticated devices update the state of the device on openSenseMap. - The API key is appended to every request made to the API. More - information can be found{' '} - - in the docs - - . - -

+
- - {t('device_security.warning_deactive_auth')} - +

+ + Devices should use their API key shown on this page to authenticate + requests sent to the openSenseMap API. This ensures that only + authenticated devices update the state of the device on + openSenseMap. The API key is appended to every request made to the + API. More information can be found{' '} + + in the docs + + . + +

-
- setAuthEnabled(!authEnabled)} - /> - -
+ + {t('device_security.warning_deactive_auth')} + -
- -
- - - - + setAuthEnabled(!authEnabled)} /> - - - +
-
-
- -

- - Generating a new key will require you to update your device (e.g. - change the sketch/ code). - This step can not be undone. - -

- -
- +
+ +
+ + + + + + + +
+
+
+ +
+ +

+ + Generating a new key will require you to update your device (e.g. + change the sketch/ code). + This step can not be undone. + +

+ +
+
+
) } From 65138365125a3db4f64afc57085a8849868ba452 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Thu, 5 Feb 2026 11:50:15 +0100 Subject: [PATCH 5/8] refactor: rename accessToken to deviceApiKey and generate on creation --- app/lib/jwt.ts | 440 +++--- app/lib/measurement-service.server.ts | 15 +- app/models/device.server.ts | 69 +- app/routes/device.$deviceId.dataupload.tsx | 6 +- app/routes/device.$deviceId.edit.security.tsx | 6 +- app/schema/accessToken.ts | 20 - app/schema/deviceApiKey.ts | 21 + app/schema/index.ts | 28 +- drizzle/0029_plain_black_widow.sql | 5 + drizzle/meta/0029_snapshot.json | 1286 +++++++++++++++++ drizzle/meta/_journal.json | 7 + tests/routes/api.boxes.spec.ts | 7 +- tests/routes/api.devices.spec.ts | 16 +- tests/routes/api.measurements.spec.ts | 4 +- 14 files changed, 1665 insertions(+), 265 deletions(-) delete mode 100644 app/schema/accessToken.ts create mode 100644 app/schema/deviceApiKey.ts create mode 100644 drizzle/0029_plain_black_widow.sql create mode 100644 drizzle/meta/0029_snapshot.json diff --git a/app/lib/jwt.ts b/app/lib/jwt.ts index cbe28e07..824e9cc7 100644 --- a/app/lib/jwt.ts +++ b/app/lib/jwt.ts @@ -1,37 +1,37 @@ -import { createHmac } from "node:crypto"; -import { eq } from "drizzle-orm"; -import jsonwebtoken, { type JwtPayload, type Algorithm } from "jsonwebtoken"; -import invariant from "tiny-invariant"; -import { v4 as uuidv4 } from "uuid"; -import { drizzleClient } from "~/db.server"; -import { getUserByEmail } from "~/models/user.server"; -import { type User } from "~/schema"; -import { refreshToken, tokenRevocation } from "~/schema/refreshToken"; - -const { sign, verify } = jsonwebtoken; +import { createHmac } from 'node:crypto' +import { eq } from 'drizzle-orm' +import jsonwebtoken, { type JwtPayload, type Algorithm } from 'jsonwebtoken' +import invariant from 'tiny-invariant' +import { v4 as uuidv4 } from 'uuid' +import { drizzleClient } from '~/db.server' +import { getUserByEmail } from '~/models/user.server' +import { device, Device, type User } from '~/schema' +import { refreshToken, tokenRevocation } from '~/schema/refreshToken' + +const { sign, verify } = jsonwebtoken const { - JWT_ALGORITHM, - JWT_ISSUER, - JWT_VALIDITY_MS, - JWT_SECRET, - REFRESH_TOKEN_ALGORITHM, - REFRESH_TOKEN_SECRET, - REFRESH_TOKEN_VALIDITY_MS, -} = process.env; + JWT_ALGORITHM, + JWT_ISSUER, + JWT_VALIDITY_MS, + JWT_SECRET, + REFRESH_TOKEN_ALGORITHM, + REFRESH_TOKEN_SECRET, + REFRESH_TOKEN_VALIDITY_MS, +} = process.env const jwtSignOptions = { - algorithm: JWT_ALGORITHM as Algorithm, - issuer: JWT_ISSUER, - expiresIn: Math.round(Number(JWT_VALIDITY_MS) / 1000), -}; + algorithm: JWT_ALGORITHM as Algorithm, + issuer: JWT_ISSUER, + expiresIn: Math.round(Number(JWT_VALIDITY_MS) / 1000), +} const jwtVerifyOptions = { - algorithm: [JWT_ALGORITHM as Algorithm], - issuer: JWT_ISSUER, -}; + algorithm: [JWT_ALGORITHM as Algorithm], + issuer: JWT_ISSUER, +} -const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; +const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24 /** * @@ -39,76 +39,76 @@ const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; * @returns */ export const createToken = ( - user: User, + user: User, ): Promise<{ - token: string; - refreshToken: string; + token: string + refreshToken: string }> => { - invariant(typeof JWT_ALGORITHM === "string"); - invariant(typeof JWT_ISSUER === "string"); - invariant(typeof JWT_VALIDITY_MS === "string"); - invariant(typeof JWT_SECRET === "string"); - - invariant(typeof REFRESH_TOKEN_VALIDITY_MS === "string"); - const payload = { role: user.role }; - const signOptions = Object.assign( - { subject: user.email, jwtid: uuidv4() }, - jwtSignOptions, - ); - - return new Promise(function (resolve, reject) { - sign( - payload, - JWT_SECRET, - signOptions, - async (err: Error | null, token: string | undefined) => { - if (err) return reject(err); - if (typeof token === "undefined") - return reject("Generated token was undefined and thus not valid"); - - // JWT generation was successful - // we now create the refreshToken. - // and set the refreshTokenExpires to 1 week - // it is a HMAC of the jwt string - const refreshToken = hashJwt(token); - const refreshTokenExpiresAt: Date = new Date( - Date.now() + Number(REFRESH_TOKEN_VALIDITY_MS), - ); - try { - await addRefreshToken(user.id, refreshToken, refreshTokenExpiresAt); - return resolve({ token, refreshToken }); - } catch (err) { - return reject(err); - } - }, - ); - }); -}; + invariant(typeof JWT_ALGORITHM === 'string') + invariant(typeof JWT_ISSUER === 'string') + invariant(typeof JWT_VALIDITY_MS === 'string') + invariant(typeof JWT_SECRET === 'string') + + invariant(typeof REFRESH_TOKEN_VALIDITY_MS === 'string') + const payload = { role: user.role } + const signOptions = Object.assign( + { subject: user.email, jwtid: uuidv4() }, + jwtSignOptions, + ) + + return new Promise(function (resolve, reject) { + sign( + payload, + JWT_SECRET, + signOptions, + async (err: Error | null, token: string | undefined) => { + if (err) return reject(err) + if (typeof token === 'undefined') + return reject('Generated token was undefined and thus not valid') + + // JWT generation was successful + // we now create the refreshToken. + // and set the refreshTokenExpires to 1 week + // it is a HMAC of the jwt string + const refreshToken = hashJwt(token) + const refreshTokenExpiresAt: Date = new Date( + Date.now() + Number(REFRESH_TOKEN_VALIDITY_MS), + ) + try { + await addRefreshToken(user.id, refreshToken, refreshTokenExpiresAt) + return resolve({ token, refreshToken }) + } catch (err) { + return reject(err) + } + }, + ) + }) +} export const revokeToken = async (user: User, jwtString: string) => { - invariant(typeof JWT_ALGORITHM === "string"); - invariant(typeof JWT_ISSUER === "string"); - invariant(typeof JWT_SECRET === "string"); - - const hash = hashJwt(jwtString); - await deleteRefreshToken(hash); - const jwt = await decodeJwtString(jwtString, JWT_SECRET, jwtVerifyOptions); - - if (jwt.jti) - await drizzleClient.insert(tokenRevocation).values({ - hash, - token: jwt, - expiresAt: jwt.exp === undefined ? new Date() : new Date(jwt.exp), - }); -}; + invariant(typeof JWT_ALGORITHM === 'string') + invariant(typeof JWT_ISSUER === 'string') + invariant(typeof JWT_SECRET === 'string') + + const hash = hashJwt(jwtString) + await deleteRefreshToken(hash) + const jwt = await decodeJwtString(jwtString, JWT_SECRET, jwtVerifyOptions) + + if (jwt.jti) + await drizzleClient.insert(tokenRevocation).values({ + hash, + token: jwt, + expiresAt: jwt.exp === undefined ? new Date() : new Date(jwt.exp), + }) +} export const revokeRefreshToken = async (refreshToken: string) => { - await drizzleClient.insert(tokenRevocation).values({ - hash: refreshToken, - token: "", - expiresAt: new Date(Date.now() + ONE_DAY_IN_MS * 7), - }); -}; + await drizzleClient.insert(tokenRevocation).values({ + hash: refreshToken, + token: '', + expiresAt: new Date(Date.now() + ONE_DAY_IN_MS * 7), + }) +} /** * @@ -116,135 +116,167 @@ export const revokeRefreshToken = async (refreshToken: string) => { * @returns */ export const getUserFromJwt = async ( - r: Request, -): Promise => { - invariant(typeof JWT_ALGORITHM === "string"); - invariant(typeof JWT_ISSUER === "string"); - invariant(typeof JWT_SECRET === "string"); - - // check if Authorization header is present - const rawAuthorizationHeader = r.headers.get("authorization"); - if (!rawAuthorizationHeader) return "no_token"; - - const [bearer, jwtString] = rawAuthorizationHeader.split(" "); - if (bearer !== "Bearer") return "invalid_token_type"; - - let decodedJwt: JwtPayload | undefined = undefined; - try { - decodedJwt = await decodeJwtString(jwtString, JWT_SECRET, { - ...jwtVerifyOptions, - ignoreExpiration: r.url.endsWith("/users/refresh-auth"), // ignore expiration for refresh endpoint - }); - } catch (err: any) { - if (typeof err === "string") return err as "verification_error"; - } - - invariant(decodedJwt !== undefined); - invariant(decodedJwt.sub !== undefined); - const user = await getUserByEmail(decodedJwt.sub); - if (!user) - throw new Error("User was not found despite a verified jwt provided"); - return user; -}; + r: Request, +): Promise => { + invariant(typeof JWT_ALGORITHM === 'string') + invariant(typeof JWT_ISSUER === 'string') + invariant(typeof JWT_SECRET === 'string') + + // check if Authorization header is present + const rawAuthorizationHeader = r.headers.get('authorization') + if (!rawAuthorizationHeader) return 'no_token' + + const [bearer, jwtString] = rawAuthorizationHeader.split(' ') + if (bearer !== 'Bearer') return 'invalid_token_type' + + let decodedJwt: JwtPayload | undefined = undefined + try { + decodedJwt = await decodeJwtString(jwtString, JWT_SECRET, { + ...jwtVerifyOptions, + ignoreExpiration: r.url.endsWith('/users/refresh-auth'), // ignore expiration for refresh endpoint + }) + } catch (err: any) { + if (typeof err === 'string') return err as 'verification_error' + } + + invariant(decodedJwt !== undefined) + invariant(decodedJwt.sub !== undefined) + const user = await getUserByEmail(decodedJwt.sub) + if (!user) + throw new Error('User was not found despite a verified jwt provided') + return user +} const decodeJwtString = ( - jwtString: string, - jwtSecret: jsonwebtoken.Secret, - options: jsonwebtoken.VerifyOptions & { complete?: false }, + jwtString: string, + jwtSecret: jsonwebtoken.Secret, + options: jsonwebtoken.VerifyOptions & { complete?: false }, ): Promise => { - return new Promise((resolve, reject) => { - verify(jwtString, jwtSecret, options, async (err, decodedJwt) => { - if (err) reject("verification_error"); - if (decodedJwt === undefined) { - reject("verification_error"); - return; - } - - // Our tokens are signed with an object, therefore tokens that - // verify and decode to a string cannot be valid - if (typeof decodedJwt === "string") { - reject("verification_error"); - return; - } - - // We sign our jwt with the user email as the subject, so if there is - // no subject, the jwt cannot be valid as well. - if (decodedJwt.sub === undefined) { - reject("verification_error"); - return; - } - - // check if the token is blacklisted by performing a hmac digest - // on the string representation of the jwt. - // also checks the existence of the jti claim - if (await isTokenRevoked(decodedJwt, jwtString)) { - reject("verification_error"); - return; - } - - resolve(decodedJwt); - return; - }); - }); -}; + return new Promise((resolve, reject) => { + verify(jwtString, jwtSecret, options, async (err, decodedJwt) => { + if (err) reject('verification_error') + if (decodedJwt === undefined) { + reject('verification_error') + return + } + + // Our tokens are signed with an object, therefore tokens that + // verify and decode to a string cannot be valid + if (typeof decodedJwt === 'string') { + reject('verification_error') + return + } + + // We sign our jwt with the user email as the subject, so if there is + // no subject, the jwt cannot be valid as well. + if (decodedJwt.sub === undefined) { + reject('verification_error') + return + } + + // check if the token is blacklisted by performing a hmac digest + // on the string representation of the jwt. + // also checks the existence of the jti claim + if (await isTokenRevoked(decodedJwt, jwtString)) { + reject('verification_error') + return + } + + resolve(decodedJwt) + return + }) + }) +} const addRefreshToken = async ( - userId: string, - token: string, - expiresAt: Date, + userId: string, + token: string, + expiresAt: Date, ) => { - await drizzleClient.insert(refreshToken).values({ - userId, - token, - expiresAt, - }); -}; + await drizzleClient.insert(refreshToken).values({ + userId, + token, + expiresAt, + }) +} const deleteRefreshToken = async (tokenHash: string) => { - await drizzleClient - .delete(refreshToken) - .where(eq(refreshToken.token, tokenHash)); -}; + await drizzleClient + .delete(refreshToken) + .where(eq(refreshToken.token, tokenHash)) +} const isTokenRevoked = async (token: JwtPayload, tokenString: string) => { - if (!token.jti) { - // token has no id.. -> shouldn't be accepted - return true; - } + if (!token.jti) { + // token has no id.. -> shouldn't be accepted + return true + } - const hash = hashJwt(tokenString); + const hash = hashJwt(tokenString) - const revokedToken = await drizzleClient - .select() - .from(tokenRevocation) - .where(eq(tokenRevocation.hash, hash)); + const revokedToken = await drizzleClient + .select() + .from(tokenRevocation) + .where(eq(tokenRevocation.hash, hash)) - if (revokedToken.length > 0) return true; - return false; -}; + if (revokedToken.length > 0) return true + return false +} export const hashJwt = (jwt: string) => { - invariant(typeof REFRESH_TOKEN_ALGORITHM === "string"); - invariant(typeof REFRESH_TOKEN_SECRET === "string"); + invariant(typeof REFRESH_TOKEN_ALGORITHM === 'string') + invariant(typeof REFRESH_TOKEN_SECRET === 'string') - return createHmac(REFRESH_TOKEN_ALGORITHM, REFRESH_TOKEN_SECRET) - .update(jwt) - .digest("base64"); -}; + return createHmac(REFRESH_TOKEN_ALGORITHM, REFRESH_TOKEN_SECRET) + .update(jwt) + .digest('base64') +} export const refreshJwt = async ( - u: User, - refreshToken: string, + u: User, + refreshToken: string, ): Promise<{ token: string; refreshToken: string } | null> => { - // We have to check if the refresh token actually belongs to the user - const userForToken = await drizzleClient.query.refreshToken.findFirst({ - where: (r, { eq }) => eq(r.token, refreshToken), - with: { - user: true, - }, - }); - if (userForToken == undefined || userForToken.userId !== u.id) return null; - - await revokeRefreshToken(refreshToken); - return await createToken(u); -}; + // We have to check if the refresh token actually belongs to the user + const userForToken = await drizzleClient.query.refreshToken.findFirst({ + where: (r, { eq }) => eq(r.token, refreshToken), + with: { + user: true, + }, + }) + if (userForToken == undefined || userForToken.userId !== u.id) return null + + await revokeRefreshToken(refreshToken) + return await createToken(u) +} + +export const createDeviceApiKey = ( + device: Device, +): Promise<{ + jwt: string +}> => { + invariant(typeof JWT_ALGORITHM === 'string') + invariant(typeof JWT_ISSUER === 'string') + invariant(typeof JWT_VALIDITY_MS === 'string') + invariant(typeof JWT_SECRET === 'string') + + const payload = { role: device.id } + const signOptions = Object.assign( + { subject: device.id, jwtid: uuidv4() }, + jwtSignOptions, + ) + + return new Promise(function (resolve, reject) { + sign( + payload, + JWT_SECRET, + signOptions, + async (err: Error | null, token: string | undefined) => { + if (err) return reject(err) + if (typeof token === 'undefined') + return reject('Generated token was undefined and thus not valid') + + return resolve({ jwt: token }) + }, + ) + }) +} diff --git a/app/lib/measurement-service.server.ts b/app/lib/measurement-service.server.ts index 6fc911be..609335a8 100644 --- a/app/lib/measurement-service.server.ts +++ b/app/lib/measurement-service.server.ts @@ -5,7 +5,7 @@ import { type DeviceWithoutSensors, getDeviceWithoutSensors, getDevice, - findAccessToken, + findDeviceApiKey, } from '~/models/device.server' import { saveMeasurements } from '~/models/measurement.server' import { @@ -137,9 +137,12 @@ export const postNewMeasurements = async ( } if (device.useAuth) { - const deviceAccessToken = await findAccessToken(deviceId) + const deviceAccessToken = await findDeviceApiKey(deviceId) - if (deviceAccessToken?.token && deviceAccessToken.token !== authorization) { + if ( + deviceAccessToken?.apiKey && + deviceAccessToken.apiKey !== authorization + ) { const error = new Error('Device access token not valid!') error.name = 'UnauthorizedError' throw error @@ -192,11 +195,11 @@ export const postSingleMeasurement = async ( } if (device.useAuth) { - const deviceAccessToken = await findAccessToken(deviceId) + const deviceAccessToken = await findDeviceApiKey(deviceId) if ( - deviceAccessToken?.token && - deviceAccessToken.token !== authorization + deviceAccessToken?.apiKey && + deviceAccessToken.apiKey !== authorization ) { const error = new Error('Device access token not valid!') error.name = 'UnauthorizedError' diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 3a232cf8..25e6b2e5 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,5 +1,16 @@ import { point } from '@turf/helpers' -import { eq, sql, desc, ilike, arrayContains, and, between } from 'drizzle-orm' +import { + eq, + sql, + desc, + ilike, + arrayContains, + and, + between, + ExtractTablesWithRelations, +} from 'drizzle-orm' +import { PgTransaction } from 'drizzle-orm/pg-core' +import { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js' import BaseNewDeviceEmail, { messages as BaseNewDeviceMessages, } from 'emails/base-new-device' @@ -7,8 +18,10 @@ import { messages as NewLufdatenDeviceMessages } from 'emails/new-device-luftdat import { messages as NewSenseboxDeviceMessages } from 'emails/new-device-sensebox' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' +import { createDeviceApiKey } from '~/lib/jwt' import { sendMail } from '~/lib/mail.server' import { + deviceApiKey, device, deviceToLocation, location, @@ -17,6 +30,7 @@ import { type Device, type Sensor, } from '~/schema' +import * as schema from '~/schema/index' import { getSensorsForModel } from '~/utils/model-definitions' const BASE_DEVICE_COLUMNS = { @@ -377,6 +391,14 @@ export async function updateDevice( } } } + + if ( + args.useAuth && + args.useAuth == updatedDevice.useAuth && + args.useAuth === true + ) + await addOrReplaceDeviceApiKey(updatedDevice, tx) + return updatedDevice }) @@ -751,6 +773,10 @@ export async function createDevice(deviceData: any, userId: string) { } } + if (createdDevice.useAuth) { + await addOrReplaceDeviceApiKey(createdDevice, tx) + } + // Return device with sensors return [ { @@ -841,14 +867,45 @@ export async function getLatestDevices() { return devices } -export async function findAccessToken( +export async function addOrReplaceDeviceApiKey( + device: Device, + tx?: PgTransaction< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + >, +): Promise<{ apiKey: string }> { + const existing = findDeviceApiKey(device.id, tx) + if (existing !== null) + await (tx ?? drizzleClient) + .delete(deviceApiKey) + .where(eq(deviceApiKey.deviceId, device.id)) + + const { jwt } = await createDeviceApiKey(device) + const result = await (tx ?? drizzleClient) + .insert(deviceApiKey) + .values({ deviceId: device.id, apiKey: jwt }) + .returning() + + if (result[0].apiKey === null) + throw new Error('device api key cannot be null after inserting') + + return { apiKey: result[0].apiKey } +} + +export async function findDeviceApiKey( deviceId: string, -): Promise<{ token: string } | null> { - const result = await drizzleClient.query.accessToken.findFirst({ + tx?: PgTransaction< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + >, +): Promise<{ apiKey: string } | null> { + const result = await (tx ?? drizzleClient).query.deviceApiKey.findFirst({ where: (token, { eq }) => eq(token.deviceId, deviceId), }) - if (!result || !result.token) return null + if (!result || !result.apiKey) return null - return { token: result.token } + return { apiKey: result.apiKey } } diff --git a/app/routes/device.$deviceId.dataupload.tsx b/app/routes/device.$deviceId.dataupload.tsx index 33159021..93a5d191 100644 --- a/app/routes/device.$deviceId.dataupload.tsx +++ b/app/routes/device.$deviceId.dataupload.tsx @@ -24,7 +24,7 @@ import { } from '~/components/ui/select' import { Textarea } from '~/components/ui/textarea' import { postNewMeasurements } from '~/lib/measurement-service.server' -import { findAccessToken } from '~/models/device.server' +import { findDeviceApiKey } from '~/models/device.server' import { StandardResponse } from '~/utils/response-utils' import { getUserId } from '~/utils/session.server' @@ -63,14 +63,14 @@ export async function action({ return StandardResponse.badRequest( 'measurement data is either not set or has a wrong type', ) - const deviceApiKey = await findAccessToken(deviceId) + const deviceApiKey = await findDeviceApiKey(deviceId) try { await postNewMeasurements(deviceId, measurementData, { contentType, luftdaten: false, hackair: false, - authorization: deviceApiKey?.token ?? '', + authorization: deviceApiKey?.apiKey ?? '', }) return StandardResponse.ok({}) diff --git a/app/routes/device.$deviceId.edit.security.tsx b/app/routes/device.$deviceId.edit.security.tsx index ce473d31..6d4d2bbc 100644 --- a/app/routes/device.$deviceId.edit.security.tsx +++ b/app/routes/device.$deviceId.edit.security.tsx @@ -18,7 +18,7 @@ import { import { Checkbox } from '@/components/ui/checkbox' import ErrorMessage from '~/components/error-message' import { Callout } from '~/components/ui/alert' -import { findAccessToken, getDevice } from '~/models/device.server' +import { findDeviceApiKey, getDevice } from '~/models/device.server' import { getUserId } from '~/utils/session.server' export async function loader({ request, params }: LoaderFunctionArgs) { @@ -29,9 +29,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const deviceId = params.deviceId if (typeof deviceId !== 'string') throw 'deviceID not found' - const t = await findAccessToken(deviceId) + const t = await findDeviceApiKey(deviceId) const device = await getDevice({ id: deviceId }) - return { key: t?.token, deviceAuthEnabled: device?.useAuth ?? false } + return { key: t?.apiKey, deviceAuthEnabled: device?.useAuth ?? false } } export async function action({ request }: ActionFunctionArgs) { diff --git a/app/schema/accessToken.ts b/app/schema/accessToken.ts deleted file mode 100644 index 956d2420..00000000 --- a/app/schema/accessToken.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type InferSelectModel, relations } from "drizzle-orm"; -import { pgTable, text } from "drizzle-orm/pg-core"; -import { device } from "./device"; - -export const accessToken = pgTable('access_token', { - deviceId: text('device_id').notNull() - .references(() => device.id, { - onDelete: 'cascade' - }), - token: text('token'), - }); - - export const accessTokenRelations = relations(accessToken, ({ one }) => ({ - user: one(device, { - fields: [accessToken.deviceId], - references: [device.id] - }) - })); - -export type AccessToken = InferSelectModel; diff --git a/app/schema/deviceApiKey.ts b/app/schema/deviceApiKey.ts new file mode 100644 index 00000000..3d171856 --- /dev/null +++ b/app/schema/deviceApiKey.ts @@ -0,0 +1,21 @@ +import { type InferSelectModel, relations } from 'drizzle-orm' +import { pgTable, text } from 'drizzle-orm/pg-core' +import { device } from './device' + +export const deviceApiKey = pgTable('device_api_key', { + deviceId: text('device_id') + .notNull() + .references(() => device.id, { + onDelete: 'cascade', + }), + apiKey: text('api_key'), +}) + +export const deviceApiKeyRelations = relations(deviceApiKey, ({ one }) => ({ + user: one(device, { + fields: [deviceApiKey.deviceId], + references: [device.id], + }), +})) + +export type DeviceApiKey = InferSelectModel diff --git a/app/schema/index.ts b/app/schema/index.ts index 8099e8d5..a10374f4 100644 --- a/app/schema/index.ts +++ b/app/schema/index.ts @@ -1,14 +1,14 @@ -export * from "./device"; -export * from "./enum"; -export * from "./measurement"; -export * from "./password"; -export * from "./profile"; -export * from "./profile-image"; -export * from "./types"; -export * from "./sensor"; -export * from "./user"; -export * from "./location"; -export * from "./log-entry"; -export * from "./refreshToken"; -export * from "./claim"; -export * from "./accessToken"; +export * from './device' +export * from './enum' +export * from './measurement' +export * from './password' +export * from './profile' +export * from './profile-image' +export * from './types' +export * from './sensor' +export * from './user' +export * from './location' +export * from './log-entry' +export * from './refreshToken' +export * from './claim' +export * from './deviceApiKey' diff --git a/drizzle/0029_plain_black_widow.sql b/drizzle/0029_plain_black_widow.sql new file mode 100644 index 00000000..1c60572d --- /dev/null +++ b/drizzle/0029_plain_black_widow.sql @@ -0,0 +1,5 @@ +ALTER TABLE "access_token" RENAME TO "device_api_key";--> statement-breakpoint +ALTER TABLE "device_api_key" RENAME COLUMN "token" TO "api_key";--> statement-breakpoint +ALTER TABLE "device_api_key" DROP CONSTRAINT "access_token_device_id_device_id_fk"; +--> statement-breakpoint +ALTER TABLE "device_api_key" ADD CONSTRAINT "device_api_key_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0029_snapshot.json b/drizzle/meta/0029_snapshot.json new file mode 100644 index 00000000..892aaf3b --- /dev/null +++ b/drizzle/meta/0029_snapshot.json @@ -0,0 +1,1286 @@ +{ + "id": "825b79a1-9079-4a5e-83ea-864930b0f7b7", + "prevId": "d622d4a4-5722-4b7f-b22f-8c5184ad7148", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'custom'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_api_key": { + "name": "device_api_key", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "device_api_key_device_id_device_id_fk": { + "name": "device_api_key_device_id_device_id_fk", + "tableFrom": "device_api_key", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "homeEthernet", + "homeWifi", + "homeEthernetFeinstaub", + "homeWifiFeinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "hackair_home_v2", + "senseBox:Edu", + "luftdaten.info", + "custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 455d73e6..049f578c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1769504950323, "tag": "0028_gigantic_deadpool", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1770285162055, + "tag": "0029_plain_black_widow", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index c258e002..edf139c1 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -3,7 +3,7 @@ import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { BASE_URL } from 'vitest.setup' import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' -import { deleteDevice } from '~/models/device.server' +import { deleteDevice, findDeviceApiKey } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' import { action } from '~/routes/api.boxes' import { type User } from '~/schema' @@ -82,13 +82,16 @@ describe('openSenseMap API Routes: /boxes', () => { createdDeviceIds.push(body._id) } + const useAuthKey = await findDeviceApiKey(body._id) + expect(response.status).toBe(201) expect(body).toHaveProperty('_id') expect(body).toHaveProperty('name', 'Test Weather Station') - expect(body).toHaveProperty('sensors') expect(body.sensors).toHaveLength(2) + expect(body).toHaveProperty('sensors') expect(body.sensors[0]).toHaveProperty('title', 'Temperature') expect(body.sensors[1]).toHaveProperty('title', 'Humidity') + expect(useAuthKey).not.toBeNull() }) it('should create a box with minimal data (no sensors)', async () => { diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 8e8dc274..61449b7a 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -7,7 +7,11 @@ import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { BASE_URL } from 'vitest.setup' import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' -import { createDevice, deleteDevice } from '~/models/device.server' +import { + createDevice, + deleteDevice, + findDeviceApiKey, +} from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' import { loader as deviceLoader, @@ -54,6 +58,7 @@ describe('openSenseMap API Routes: /boxes', () => { latitude: 123, longitude: 12, tags: ['testgroup'], + useAuth: false, }, (testUser as User).id, ) @@ -625,6 +630,7 @@ describe('openSenseMap API Routes: /boxes', () => { description: 'total neue beschreibung', location: { lat: 54.2, lng: 21.1 }, weblink: 'http://www.google.de', + useAuth: true, image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', } @@ -643,17 +649,17 @@ describe('openSenseMap API Routes: /boxes', () => { params: { deviceId: queryableDevice?.id }, context: {} as AppLoadContext, } as ActionFunctionArgs) - - expect(response.status).toBe(200) - const data = await response.json() + const useAuthKey = await findDeviceApiKey(queryableDevice!.id) + + expect(response.status).toBe(200) expect(data.name).toBe(update_payload.name) expect(data.exposure).toBe(update_payload.exposure) expect(Array.isArray(data.grouptag)).toBe(true) expect(data.grouptag).toContain(update_payload.grouptag) expect(data.description).toBe(update_payload.description) - + expect(useAuthKey).not.toBeNull() expect(data.currentLocation).toEqual({ type: 'Point', coordinates: [ diff --git a/tests/routes/api.measurements.spec.ts b/tests/routes/api.measurements.spec.ts index 7414ba8c..06a64af0 100644 --- a/tests/routes/api.measurements.spec.ts +++ b/tests/routes/api.measurements.spec.ts @@ -9,7 +9,7 @@ import { createDevice, deleteDevice, getDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' import { action as postSingleMeasurementAction } from '~/routes/api.boxes.$deviceId.$sensorId' import { action as postMeasurementsAction } from '~/routes/api.boxes.$deviceId.data' -import { accessToken, type User } from '~/schema' +import { deviceApiKey, type User } from '~/schema' const mockAccessToken = 'valid-access-token' @@ -53,7 +53,7 @@ describe('openSenseMap API Routes: /boxes', () => { deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || [] sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || [] - await drizzleClient.insert(accessToken).values({ + await drizzleClient.insert(deviceApiKey).values({ deviceId: deviceId, token: 'valid-access-token', }) From 28b67b8477fd0723f3268d7aa176ed5dc1d02adb Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Thu, 5 Feb 2026 15:02:54 +0100 Subject: [PATCH 6/8] feat: add copy animation --- app/routes/device.$deviceId.edit.security.tsx | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/app/routes/device.$deviceId.edit.security.tsx b/app/routes/device.$deviceId.edit.security.tsx index 6d4d2bbc..819783cf 100644 --- a/app/routes/device.$deviceId.edit.security.tsx +++ b/app/routes/device.$deviceId.edit.security.tsx @@ -1,12 +1,13 @@ import { Label } from '@radix-ui/react-label' import { LucideCopy, + LucideCopyCheck, LucideEye, LucideEyeOff, RefreshCw, Save, } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { type LoaderFunctionArgs, @@ -18,7 +19,12 @@ import { import { Checkbox } from '@/components/ui/checkbox' import ErrorMessage from '~/components/error-message' import { Callout } from '~/components/ui/alert' -import { findDeviceApiKey, getDevice } from '~/models/device.server' +import { + addOrReplaceDeviceApiKey, + findDeviceApiKey, + getDevice, + updateDevice, +} from '~/models/device.server' import { getUserId } from '~/utils/session.server' export async function loader({ request, params }: LoaderFunctionArgs) { @@ -34,17 +40,23 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return { key: t?.apiKey, deviceAuthEnabled: device?.useAuth ?? false } } -export async function action({ request }: ActionFunctionArgs) { +export async function action({ request, params }: ActionFunctionArgs) { + const { deviceId } = params + if (typeof deviceId !== 'string') throw 'deviceID not found' + switch (request.method) { case 'POST': + const formData = await request.formData() + const enableAuth = formData.has('enableAuth') + await updateDevice(deviceId, { useAuth: enableAuth }) break case 'PUT': + const device = await getDevice({ id: deviceId }) + if (device === undefined) throw 'device not found' + await addOrReplaceDeviceApiKey(device) break } - const formData = await request.formData() - console.log(formData) - return '' } @@ -53,11 +65,25 @@ export default function EditBoxSecurity() { const { key, deviceAuthEnabled } = useLoaderData() const [keyVisible, setTokenvisibility] = useState(false) const [authEnabled, setAuthEnabled] = useState(deviceAuthEnabled) + const [copiedToClipboard, setCopiedToClipboard] = useState(false) const copyKeyToClipboard = async () => { - if (key) await navigator.clipboard.writeText(key) + if (!key) return + await navigator.clipboard.writeText(key) + setCopiedToClipboard(true) } + useEffect(() => { + if (!copiedToClipboard) return + const timer = window.setTimeout(() => { + setCopiedToClipboard(false) + }, 2_500) + + return () => { + window.clearTimeout(timer) + } + }, [copiedToClipboard]) + return (
@@ -93,12 +119,13 @@ export default function EditBoxSecurity() {
setAuthEnabled(!authEnabled)} /> -
@@ -131,12 +158,19 @@ export default function EditBoxSecurity() { />
From 1b5a5c36f17ca69c574cda35b3a5b6a9a9e8049c Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Thu, 5 Feb 2026 15:03:06 +0100 Subject: [PATCH 7/8] feat: only use signature part of jwt for device api key --- app/lib/jwt.ts | 8 ++++++-- app/models/device.server.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/lib/jwt.ts b/app/lib/jwt.ts index 824e9cc7..4058e138 100644 --- a/app/lib/jwt.ts +++ b/app/lib/jwt.ts @@ -252,7 +252,7 @@ export const refreshJwt = async ( export const createDeviceApiKey = ( device: Device, ): Promise<{ - jwt: string + key: string }> => { invariant(typeof JWT_ALGORITHM === 'string') invariant(typeof JWT_ISSUER === 'string') @@ -275,7 +275,11 @@ export const createDeviceApiKey = ( if (typeof token === 'undefined') return reject('Generated token was undefined and thus not valid') - return resolve({ jwt: token }) + // We use the signature part of the jwt as a deviceApiKey + const k = token.split('.').at(-1) + if (k === undefined) + throw new Error('signature part of jwt may never be undefined') + return resolve({ key: k }) }, ) }) diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 25e6b2e5..5fa17878 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -51,6 +51,8 @@ const BASE_DEVICE_COLUMNS = { expiresAt: true, useAuth: true, sensorWikiModel: true, + public: true, + userId: true, } as const const DEVICE_COLUMNS_WITH_SENSORS = { @@ -880,11 +882,12 @@ export async function addOrReplaceDeviceApiKey( await (tx ?? drizzleClient) .delete(deviceApiKey) .where(eq(deviceApiKey.deviceId, device.id)) + .returning() - const { jwt } = await createDeviceApiKey(device) + const { key } = await createDeviceApiKey(device) const result = await (tx ?? drizzleClient) .insert(deviceApiKey) - .values({ deviceId: device.id, apiKey: jwt }) + .values({ deviceId: device.id, apiKey: key }) .returning() if (result[0].apiKey === null) @@ -902,7 +905,7 @@ export async function findDeviceApiKey( >, ): Promise<{ apiKey: string } | null> { const result = await (tx ?? drizzleClient).query.deviceApiKey.findFirst({ - where: (token, { eq }) => eq(token.deviceId, deviceId), + where: (key, { eq }) => eq(key.deviceId, deviceId), }) if (!result || !result.apiKey) return null From 8b7b347f2aa5fd62bf5c652339ba384523423321 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Thu, 5 Feb 2026 16:36:29 +0100 Subject: [PATCH 8/8] feat: replace device api key table with column on device --- app/lib/device-transform.ts | 4 +- app/lib/measurement-service.server.ts | 15 +- app/models/device.server.ts | 47 +- app/routes/device.$deviceId.dataupload.tsx | 7 +- app/routes/device.$deviceId.edit.security.tsx | 7 +- app/schema/device.ts | 1 + app/schema/deviceApiKey.ts | 21 - app/schema/index.ts | 1 - drizzle/0030_parallel_toxin.sql | 2 + drizzle/meta/0030_snapshot.json | 1253 +++++++++++++++++ drizzle/meta/_journal.json | 7 + tests/routes/api.boxes.spec.ts | 7 +- tests/routes/api.devices.spec.ts | 10 +- tests/routes/api.location.spec.ts | 34 +- tests/routes/api.measurements.spec.ts | 38 +- tests/routes/api.tags.spec.ts | 6 +- tests/routes/api.users.me.boxes.spec.ts | 1 + 17 files changed, 1332 insertions(+), 129 deletions(-) delete mode 100644 app/schema/deviceApiKey.ts create mode 100644 drizzle/0030_parallel_toxin.sql create mode 100644 drizzle/meta/0030_snapshot.json diff --git a/app/lib/device-transform.ts b/app/lib/device-transform.ts index 399c3a17..dcdc6be0 100644 --- a/app/lib/device-transform.ts +++ b/app/lib/device-transform.ts @@ -17,6 +17,7 @@ export type TransformedDevice = { latitude: number longitude: number useAuth: boolean | null + access_token: string | null public: boolean | null status: string | null createdAt: Date @@ -65,7 +66,7 @@ export type TransformedDevice = { export function transformDeviceToApiFormat( box: DeviceWithSensors, ): TransformedDevice { - const { id, tags, sensors, ...rest } = box + const { id, tags, sensors, apiKey, ...rest } = box const timestamp = box.updatedAt.toISOString() const coordinates = [box.longitude, box.latitude] @@ -86,6 +87,7 @@ export function transformDeviceToApiFormat( }, ], integrations: { mqtt: { enabled: false } }, + access_token: apiKey, sensors: sensors?.map((sensor) => ({ _id: sensor.id, diff --git a/app/lib/measurement-service.server.ts b/app/lib/measurement-service.server.ts index 609335a8..608872c6 100644 --- a/app/lib/measurement-service.server.ts +++ b/app/lib/measurement-service.server.ts @@ -5,7 +5,6 @@ import { type DeviceWithoutSensors, getDeviceWithoutSensors, getDevice, - findDeviceApiKey, } from '~/models/device.server' import { saveMeasurements } from '~/models/measurement.server' import { @@ -137,12 +136,7 @@ export const postNewMeasurements = async ( } if (device.useAuth) { - const deviceAccessToken = await findDeviceApiKey(deviceId) - - if ( - deviceAccessToken?.apiKey && - deviceAccessToken.apiKey !== authorization - ) { + if (device.apiKey !== authorization) { const error = new Error('Device access token not valid!') error.name = 'UnauthorizedError' throw error @@ -195,12 +189,7 @@ export const postSingleMeasurement = async ( } if (device.useAuth) { - const deviceAccessToken = await findDeviceApiKey(deviceId) - - if ( - deviceAccessToken?.apiKey && - deviceAccessToken.apiKey !== authorization - ) { + if (device.apiKey !== authorization) { const error = new Error('Device access token not valid!') error.name = 'UnauthorizedError' throw error diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 5fa17878..7df61d96 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -7,10 +7,10 @@ import { arrayContains, and, between, - ExtractTablesWithRelations, + type ExtractTablesWithRelations, } from 'drizzle-orm' -import { PgTransaction } from 'drizzle-orm/pg-core' -import { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js' +import { type PgTransaction } from 'drizzle-orm/pg-core' +import { type PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js' import BaseNewDeviceEmail, { messages as BaseNewDeviceMessages, } from 'emails/base-new-device' @@ -21,7 +21,6 @@ import { drizzleClient } from '~/db.server' import { createDeviceApiKey } from '~/lib/jwt' import { sendMail } from '~/lib/mail.server' import { - deviceApiKey, device, deviceToLocation, location, @@ -30,7 +29,7 @@ import { type Device, type Sensor, } from '~/schema' -import * as schema from '~/schema/index' +import type * as schema from '~/schema/index' import { getSensorsForModel } from '~/utils/model-definitions' const BASE_DEVICE_COLUMNS = { @@ -50,6 +49,7 @@ const BASE_DEVICE_COLUMNS = { updatedAt: true, expiresAt: true, useAuth: true, + apiKey: true, sensorWikiModel: true, public: true, userId: true, @@ -775,8 +775,9 @@ export async function createDevice(deviceData: any, userId: string) { } } + let apiKey: string = '' if (createdDevice.useAuth) { - await addOrReplaceDeviceApiKey(createdDevice, tx) + apiKey = (await addOrReplaceDeviceApiKey(createdDevice, tx)).apiKey } // Return device with sensors @@ -784,6 +785,7 @@ export async function createDevice(deviceData: any, userId: string) { { ...createdDevice, sensors: createdSensors, + apiKey: apiKey, }, u, ] @@ -870,24 +872,18 @@ export async function getLatestDevices() { } export async function addOrReplaceDeviceApiKey( - device: Device, + d: Device, tx?: PgTransaction< PostgresJsQueryResultHKT, typeof schema, ExtractTablesWithRelations >, ): Promise<{ apiKey: string }> { - const existing = findDeviceApiKey(device.id, tx) - if (existing !== null) - await (tx ?? drizzleClient) - .delete(deviceApiKey) - .where(eq(deviceApiKey.deviceId, device.id)) - .returning() - - const { key } = await createDeviceApiKey(device) + const { key } = await createDeviceApiKey(d) const result = await (tx ?? drizzleClient) - .insert(deviceApiKey) - .values({ deviceId: device.id, apiKey: key }) + .update(device) + .set({ apiKey: key }) + .where(eq(device.id, d.id)) .returning() if (result[0].apiKey === null) @@ -895,20 +891,3 @@ export async function addOrReplaceDeviceApiKey( return { apiKey: result[0].apiKey } } - -export async function findDeviceApiKey( - deviceId: string, - tx?: PgTransaction< - PostgresJsQueryResultHKT, - typeof schema, - ExtractTablesWithRelations - >, -): Promise<{ apiKey: string } | null> { - const result = await (tx ?? drizzleClient).query.deviceApiKey.findFirst({ - where: (key, { eq }) => eq(key.deviceId, deviceId), - }) - - if (!result || !result.apiKey) return null - - return { apiKey: result.apiKey } -} diff --git a/app/routes/device.$deviceId.dataupload.tsx b/app/routes/device.$deviceId.dataupload.tsx index 93a5d191..34217812 100644 --- a/app/routes/device.$deviceId.dataupload.tsx +++ b/app/routes/device.$deviceId.dataupload.tsx @@ -24,7 +24,7 @@ import { } from '~/components/ui/select' import { Textarea } from '~/components/ui/textarea' import { postNewMeasurements } from '~/lib/measurement-service.server' -import { findDeviceApiKey } from '~/models/device.server' +import { getDevice } from '~/models/device.server' import { StandardResponse } from '~/utils/response-utils' import { getUserId } from '~/utils/session.server' @@ -63,14 +63,15 @@ export async function action({ return StandardResponse.badRequest( 'measurement data is either not set or has a wrong type', ) - const deviceApiKey = await findDeviceApiKey(deviceId) + const deviceApiKey = (await getDevice({ id: deviceId }))?.apiKey + if (!deviceApiKey) return StandardResponse.badRequest('device not found') try { await postNewMeasurements(deviceId, measurementData, { contentType, luftdaten: false, hackair: false, - authorization: deviceApiKey?.apiKey ?? '', + authorization: deviceApiKey, }) return StandardResponse.ok({}) diff --git a/app/routes/device.$deviceId.edit.security.tsx b/app/routes/device.$deviceId.edit.security.tsx index 819783cf..365ed84e 100644 --- a/app/routes/device.$deviceId.edit.security.tsx +++ b/app/routes/device.$deviceId.edit.security.tsx @@ -21,7 +21,6 @@ import ErrorMessage from '~/components/error-message' import { Callout } from '~/components/ui/alert' import { addOrReplaceDeviceApiKey, - findDeviceApiKey, getDevice, updateDevice, } from '~/models/device.server' @@ -35,9 +34,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const deviceId = params.deviceId if (typeof deviceId !== 'string') throw 'deviceID not found' - const t = await findDeviceApiKey(deviceId) + const t = (await getDevice({ id: deviceId }))?.apiKey + if (!t) throw 'device not found' + const device = await getDevice({ id: deviceId }) - return { key: t?.apiKey, deviceAuthEnabled: device?.useAuth ?? false } + return { key: t, deviceAuthEnabled: device?.useAuth ?? false } } export async function action({ request, params }: ActionFunctionArgs) { diff --git a/app/schema/device.ts b/app/schema/device.ts index c9783b7e..db58f8a6 100644 --- a/app/schema/device.ts +++ b/app/schema/device.ts @@ -39,6 +39,7 @@ export const device = pgTable('device', { .default(sql`ARRAY[]::text[]`), link: text('link'), useAuth: boolean('use_auth'), + apiKey: text('apiKey'), exposure: DeviceExposureEnum('exposure'), status: DeviceStatusEnum('status').default('inactive'), model: DeviceModelEnum('model').default('custom'), diff --git a/app/schema/deviceApiKey.ts b/app/schema/deviceApiKey.ts deleted file mode 100644 index 3d171856..00000000 --- a/app/schema/deviceApiKey.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type InferSelectModel, relations } from 'drizzle-orm' -import { pgTable, text } from 'drizzle-orm/pg-core' -import { device } from './device' - -export const deviceApiKey = pgTable('device_api_key', { - deviceId: text('device_id') - .notNull() - .references(() => device.id, { - onDelete: 'cascade', - }), - apiKey: text('api_key'), -}) - -export const deviceApiKeyRelations = relations(deviceApiKey, ({ one }) => ({ - user: one(device, { - fields: [deviceApiKey.deviceId], - references: [device.id], - }), -})) - -export type DeviceApiKey = InferSelectModel diff --git a/app/schema/index.ts b/app/schema/index.ts index a10374f4..e597d199 100644 --- a/app/schema/index.ts +++ b/app/schema/index.ts @@ -11,4 +11,3 @@ export * from './location' export * from './log-entry' export * from './refreshToken' export * from './claim' -export * from './deviceApiKey' diff --git a/drizzle/0030_parallel_toxin.sql b/drizzle/0030_parallel_toxin.sql new file mode 100644 index 00000000..f2e7efc5 --- /dev/null +++ b/drizzle/0030_parallel_toxin.sql @@ -0,0 +1,2 @@ +DROP TABLE "device_api_key" CASCADE;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "apiKey" text; \ No newline at end of file diff --git a/drizzle/meta/0030_snapshot.json b/drizzle/meta/0030_snapshot.json new file mode 100644 index 00000000..32268176 --- /dev/null +++ b/drizzle/meta/0030_snapshot.json @@ -0,0 +1,1253 @@ +{ + "id": "962a5afc-671c-43ef-b967-5571d0fc3776", + "prevId": "825b79a1-9079-4a5e-83ea-864930b0f7b7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'custom'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "homeEthernet", + "homeWifi", + "homeEthernetFeinstaub", + "homeWifiFeinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "hackair_home_v2", + "senseBox:Edu", + "luftdaten.info", + "custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 049f578c..9c94b36f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1770285162055, "tag": "0029_plain_black_widow", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1770303921106, + "tag": "0030_parallel_toxin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index edf139c1..2a8f764d 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -3,7 +3,7 @@ import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { BASE_URL } from 'vitest.setup' import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' -import { deleteDevice, findDeviceApiKey } from '~/models/device.server' +import { deleteDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' import { action } from '~/routes/api.boxes' import { type User } from '~/schema' @@ -82,8 +82,6 @@ describe('openSenseMap API Routes: /boxes', () => { createdDeviceIds.push(body._id) } - const useAuthKey = await findDeviceApiKey(body._id) - expect(response.status).toBe(201) expect(body).toHaveProperty('_id') expect(body).toHaveProperty('name', 'Test Weather Station') @@ -91,7 +89,8 @@ describe('openSenseMap API Routes: /boxes', () => { expect(body).toHaveProperty('sensors') expect(body.sensors[0]).toHaveProperty('title', 'Temperature') expect(body.sensors[1]).toHaveProperty('title', 'Humidity') - expect(useAuthKey).not.toBeNull() + expect(body).toHaveProperty('access_token') + expect(body.access_token).not.toBeNull() }) it('should create a box with minimal data (no sensors)', async () => { diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 61449b7a..e80439d6 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -7,11 +7,7 @@ import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { BASE_URL } from 'vitest.setup' import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' -import { - createDevice, - deleteDevice, - findDeviceApiKey, -} from '~/models/device.server' +import { createDevice, deleteDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' import { loader as deviceLoader, @@ -651,15 +647,13 @@ describe('openSenseMap API Routes: /boxes', () => { } as ActionFunctionArgs) const data = await response.json() - const useAuthKey = await findDeviceApiKey(queryableDevice!.id) - expect(response.status).toBe(200) expect(data.name).toBe(update_payload.name) expect(data.exposure).toBe(update_payload.exposure) expect(Array.isArray(data.grouptag)).toBe(true) expect(data.grouptag).toContain(update_payload.grouptag) expect(data.description).toBe(update_payload.description) - expect(useAuthKey).not.toBeNull() + expect(data.access_token).not.toBeNull() expect(data.currentLocation).toEqual({ type: 'Point', coordinates: [ diff --git a/tests/routes/api.location.spec.ts b/tests/routes/api.location.spec.ts index ec572df8..b2da69ee 100644 --- a/tests/routes/api.location.spec.ts +++ b/tests/routes/api.location.spec.ts @@ -11,8 +11,6 @@ import { action as postSingleMeasurementAction } from '~/routes/api.boxes.$devic import { action as postMeasurementsAction } from '~/routes/api.boxes.$deviceId.data' import { location, deviceToLocation, measurement, type User } from '~/schema' -const mockAccessToken = 'valid-access-token-location-tests' - const TEST_USER = generateTestUserCredentials() const TEST_BOX = { @@ -36,6 +34,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { let deviceId: string = '' let sensorIds: string[] = [] let sensors: any[] = [] + let deviceApiKey: string = '' // Helper function to get device's current location async function getDeviceCurrentLocation(deviceId: string) { @@ -121,6 +120,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { userId = (user as User).id const device = await createDevice(TEST_BOX, userId) deviceId = device.id + deviceApiKey = device.apiKey const deviceWithSensors = await getDevice({ id: deviceId }) sensorIds = @@ -146,7 +146,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(measurement), }, @@ -180,7 +180,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(measurement), }, @@ -214,7 +214,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(currentMeasurement), }, @@ -245,7 +245,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(pastMeasurement), }, @@ -290,7 +290,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: testDevice.apiKey, }, body: JSON.stringify(measurement), }, @@ -339,7 +339,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: testDevice.apiKey, }, body: JSON.stringify(measurement1), }, @@ -365,7 +365,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: testDevice.apiKey, }, body: JSON.stringify(measurement2), }, @@ -390,7 +390,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: testDevice.apiKey, }, body: JSON.stringify(measurement3), }, @@ -450,7 +450,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: testDevice.apiKey, }, body: JSON.stringify(measurement3), }, @@ -475,7 +475,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: testDevice.apiKey, }, body: JSON.stringify(measurement2), }, @@ -501,7 +501,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: testDevice.apiKey, }, body: JSON.stringify(measurement1), }, @@ -552,7 +552,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(measurement), }, @@ -582,7 +582,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(measurement), }, @@ -616,7 +616,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(body), }) @@ -658,7 +658,7 @@ describe('openSenseMap API Routes: Location Measurements', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(measurements), }) diff --git a/tests/routes/api.measurements.spec.ts b/tests/routes/api.measurements.spec.ts index 06a64af0..98b6bd58 100644 --- a/tests/routes/api.measurements.spec.ts +++ b/tests/routes/api.measurements.spec.ts @@ -3,15 +3,12 @@ import { csvExampleData, jsonSubmitData, byteSubmitData } from 'tests/data' import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { BASE_URL } from 'vitest.setup' -import { drizzleClient } from '~/db.server' import { registerUser } from '~/lib/user-service.server' import { createDevice, deleteDevice, getDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' import { action as postSingleMeasurementAction } from '~/routes/api.boxes.$deviceId.$sensorId' import { action as postMeasurementsAction } from '~/routes/api.boxes.$deviceId.data' -import { deviceApiKey, type User } from '~/schema' - -const mockAccessToken = 'valid-access-token' +import { type User } from '~/schema' const TEST_USER = generateTestUserCredentials() @@ -36,6 +33,7 @@ describe('openSenseMap API Routes: /boxes', () => { let deviceId: string = '' let sensorIds: string[] = [] let sensors: any[] = [] + let deviceApiKey: string = '' beforeAll(async () => { const user = await registerUser( @@ -52,11 +50,7 @@ describe('openSenseMap API Routes: /boxes', () => { sensorIds = deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || [] sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || [] - - await drizzleClient.insert(deviceApiKey).values({ - deviceId: deviceId, - token: 'valid-access-token', - }) + deviceApiKey = deviceWithSensors?.apiKey ?? '' }) // --------------------------------------------------- @@ -70,7 +64,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify({ value: 312.1 }), }, @@ -120,7 +114,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify({ value: 123.4, createdAt: timestamp }), }, @@ -145,7 +139,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify({ value: 123.4, createdAt: future }), }, @@ -172,7 +166,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'text/csv', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: csvPayload, }) @@ -194,7 +188,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'text/csv', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: csvPayload, }) @@ -215,7 +209,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'text/csv', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: csvPayload, }) @@ -240,7 +234,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/sbx-bytes', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: byteSubmitData(sensors) as unknown as BodyInit, }) @@ -278,7 +272,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/sbx-bytes-ts', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: byteSubmitData(sensors, true) as unknown as BodyInit, }) @@ -342,7 +336,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/sbx-bytes', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: bytes, }) @@ -364,7 +358,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/sbx-bytes', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: new Uint8Array(0), }) @@ -420,7 +414,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(submitData), }) @@ -457,7 +451,7 @@ describe('openSenseMap API Routes: /boxes', () => { const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { method: 'POST', headers: { - Authorization: mockAccessToken, + Authorization: deviceApiKey, // TODO: remove header here 'Content-Type': 'application/json', }, @@ -495,7 +489,7 @@ describe('openSenseMap API Routes: /boxes', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: mockAccessToken, + Authorization: deviceApiKey, }, body: JSON.stringify(submitData), }) diff --git a/tests/routes/api.tags.spec.ts b/tests/routes/api.tags.spec.ts index c7c0ab93..5e592385 100644 --- a/tests/routes/api.tags.spec.ts +++ b/tests/routes/api.tags.spec.ts @@ -12,7 +12,7 @@ const TEST_TAG_BOX = { name: `'${TAGS_TEST_USER.name}'s Box`, exposure: 'outdoor', expiresAt: null, - tags: ['tag1', 'tag2', 'testgroup'], + tags: ['tag1', 'tag2', 'testgrouptag'], latitude: 0, longitude: 0, model: 'luftdaten.info', @@ -79,7 +79,9 @@ describe('openSenseMap API Routes: /tags', () => { 'application/json; charset=utf-8', ) expect(Array.isArray(body.data)).toBe(true) - expect(body.data).toHaveLength(3) + expect( + body.data.filter((t: string) => TEST_TAG_BOX.tags.includes(t)), + ).toHaveLength(3) }) afterAll(async () => { diff --git a/tests/routes/api.users.me.boxes.spec.ts b/tests/routes/api.users.me.boxes.spec.ts index 6bf064df..7b0f86dd 100644 --- a/tests/routes/api.users.me.boxes.spec.ts +++ b/tests/routes/api.users.me.boxes.spec.ts @@ -78,6 +78,7 @@ describe('openSenseMap API Routes: /users', () => { expect(box).toHaveProperty('createdAt') expect(box).toHaveProperty('updatedAt') expect(box).toHaveProperty('useAuth') + expect(box).toHaveProperty('access_token') // kept for backwards compatibility, now called apiKey expect(box).toHaveProperty('currentLocation') expect(box.currentLocation).toHaveProperty('type', 'Point')