diff --git a/app/models/transfer.server.ts b/app/models/transfer.server.ts index dcc8fd26..3d0b1233 100644 --- a/app/models/transfer.server.ts +++ b/app/models/transfer.server.ts @@ -1,6 +1,7 @@ -import { eq } from 'drizzle-orm' +import { randomBytes } from 'node:crypto' +import { eq, and } from 'drizzle-orm' import { drizzleClient } from '~/db.server' -import { type Claim, claim, device, type Device } from '~/schema' +import { type Claim, claim, type Device } from '~/schema' export interface TransferCode { id: string @@ -45,8 +46,7 @@ export const createTransfer = async ( } export const generateTransferCode = (): string => { - const crypto = require('crypto') - return crypto.randomBytes(6).toString('hex') + return randomBytes(6).toString('hex') } export function getTransfer({ id }: Pick) { @@ -78,7 +78,7 @@ export const removeTransfer = async ( const [existingClaim] = await drizzleClient .select() .from(claim) - .where(eq(claim.token, token) && eq(claim.boxId, boxId)) + .where(and(eq(claim.token, token), eq(claim.boxId, boxId))) if (!existingClaim) { throw new Error('Transfer token not found') diff --git a/app/routes/device.$deviceId.edit.transfer.tsx b/app/routes/device.$deviceId.edit.transfer.tsx index 16e45e39..1c1cd4bc 100644 --- a/app/routes/device.$deviceId.edit.transfer.tsx +++ b/app/routes/device.$deviceId.edit.transfer.tsx @@ -1,33 +1,180 @@ -import { Info } from 'lucide-react' -import { type LoaderFunctionArgs, redirect, Form } from 'react-router' +import { Check, Copy, Info } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + Form, + redirect, + useActionData, + useLoaderData, + useNavigation, +} from 'react-router' import ErrorMessage from '~/components/error-message' +import { getBoxTransfer, createBoxTransfer } from '~/lib/transfer-service.server' +import { getDevice } from '~/models/device.server' +import { type Claim } from '~/schema' import { getUserId } from '~/utils/session.server' -//***************************************************** -export async function loader({ request }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home +type LoaderData = { + deviceId: string + deviceName: string + existingTransfer: Claim | null +} + +type ActionData = { + success: boolean + message?: string + error?: string + transfer?: Claim +} + +export async function loader({ + request, + params, +}: LoaderFunctionArgs): Promise { const userId = await getUserId(request) if (!userId) return redirect('/') - return '' + const deviceId = params.deviceId + if (!deviceId) { + throw new Response('Missing deviceId', { status: 400 }) + } + + const device = await getDevice({ id: deviceId }) + if (!device) { + throw new Response('Device not found', { status: 404 }) + } + + if (device.user.id !== userId) { + throw new Response('Forbidden', { status: 403 }) + } + + let existingTransfer: Claim | null = null + + try { + existingTransfer = await getBoxTransfer(userId, deviceId) + } catch (err) { + const message = err instanceof Error ? err.message : '' + if ( + !message.includes('Transfer not found') && + !message.includes('expired') + ) { + throw err + } + } + + return { + deviceId, + deviceName: device.name ?? device.id, + existingTransfer, + } } -//***************************************************** -export async function action() { - return '' +export async function action({ + request, + params, +}: ActionFunctionArgs): Promise { + const userId = await getUserId(request) + if (!userId) return redirect('/') + + const deviceId = params.deviceId + if (!deviceId) { + return { + success: false, + error: 'Missing deviceId', + } + } + + const formData = await request.formData() + const expiration = formData.get('expiration')?.toString() + const confirmation = formData.get('type')?.toString()?.trim() + + const device = await getDevice({ id: deviceId }) + if (!device) { + return { + success: false, + error: 'Device not found', + } + } + + const deviceName = device.name ?? device.id + + if (confirmation !== deviceName) { + return { + success: false, + error: `Please type "${deviceName}" to confirm.`, + } + } + + const days = Number(expiration) + if (!Number.isFinite(days) || days <= 0) { + return { + success: false, + error: 'Invalid expiration value', + } + } + + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + days) + + try { + const transfer = await createBoxTransfer( + userId, + deviceId, + expiresAt.toISOString(), + ) + + return { + success: true, + message: 'Device successfully prepared for transfer', + transfer, + } + } catch (err) { + return { + success: false, + error: + err instanceof Error ? err.message : 'Failed to create transfer.', + } + } } -//********************************** -export default function EditBoxTransfer() { +export default function EditDeviceTransfer() { + const { deviceName, existingTransfer } = useLoaderData() + const actionData = useActionData() + const navigation = useNavigation() + const { t } = useTranslation('device-transfer') + + const [copied, setCopied] = useState(false) + + const isSubmitting = navigation.state === 'submitting' + const transfer = actionData?.transfer ?? existingTransfer + const transferToken = transfer?.token + const transferExpiresAt = transfer?.expiresAt + + useEffect(() => { + if (!copied) return + const timeout = window.setTimeout(() => setCopied(false), 2000) + return () => window.clearTimeout(timeout) + }, [copied]) + + const handleCopyToken = async () => { + if (!transferToken) return + + try { + await navigator.clipboard.writeText(transferToken) + setCopied(true) + } catch (err) { + console.error('Failed to copy token:', err) + } + } + return (
- {/* Form */}
- {/* Heading */}
- {/* Title */}

Transfer

@@ -35,65 +182,56 @@ export default function EditBoxTransfer() {
- {/* divider */}

- Transfer this device to another user! -

-
-

- To perform the transfer, enter the name below and click the - button. A token will be displayed. You pass this{' '} - token to the new owner. The new owner has to enter the - token in his account and click on Claim device. After - that the device will be transferred to the new account. -
-
- The transfer may be delayed until the new owner has entered the{' '} - token. + {t('transfer_device')}

- {/* Expiration */}
- {/* Type */}
- {/* Transfer button */} + + {actionData?.error ? ( +
+ {actionData.error} +
+ ) : null} + + {transferToken ? ( +
+

+ {actionData?.transfer + ? t('transfer_created') + : t('active_token')} +

+

{t('give_token')}

+ +
+ + {transferToken} + + + +
+ + {transferExpiresAt ? ( +

+ {t('valid_until')}{' '} + + {new Date(transferExpiresAt).toLocaleString(t('locale'), { + dateStyle: 'medium', + timeStyle: 'short', + })} + +

+ ) : null} +
+ ) : null}
@@ -122,4 +314,4 @@ export function ErrorBoundary() {
) -} +} \ No newline at end of file diff --git a/app/routes/profile.$username.tsx b/app/routes/profile.$username.tsx index 38d41758..58055a44 100644 --- a/app/routes/profile.$username.tsx +++ b/app/routes/profile.$username.tsx @@ -1,10 +1,18 @@ import { useTranslation } from 'react-i18next' -import { type LoaderFunctionArgs, redirect, useLoaderData } from 'react-router' -import ErrorMessage from '~/components/error-message' +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + Form, + redirect, + useActionData, + useLoaderData, + useNavigation, +} from 'react-router' import { getColumns } from '~/components/mydevices/dt/columns' import { DataTable } from '~/components/mydevices/dt/data-table' import { NavBar } from '~/components/nav-bar' import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar' +import { claimBox } from '~/lib/transfer-service.server' import { getProfileByUsername, getProfileSensorsAndMeasurementsCount, @@ -12,80 +20,120 @@ import { import { formatCount, getInitials } from '~/utils/misc' import { getUserId } from '~/utils/session.server' +type ActionData = { + success: boolean + message?: string + error?: string + claimedBoxId?: string +} + export async function loader({ params, request }: LoaderFunctionArgs) { const requestingUserId = await getUserId(request) - // Get username or userid from URL params const username = params.username let sensorsCount = '0' let measurementsCount = '0' if (username) { - // Check if user exists const profile = await getProfileByUsername(username) + if (profile) { - // Get sensors and measurements count const counts = await getProfileSensorsAndMeasurementsCount(profile) sensorsCount = counts.sensorsCount measurementsCount = counts.measurementsCount } - // If the user exists and their profile is public, fetch their data or + if ( (!profile || !profile.public) && requestingUserId !== profile?.user?.id ) { return redirect('/explore') - } else { - // const profileMail = profile?.user?.email || '' - // Get the access token using the getMyBadgesAccessToken function - // const authToken = await getMyBadgesAccessToken().then((authData) => { - // return authData.access_token; - // }); - - // // Retrieve the user's backpack data and all available badges from the server - // const backpackData = await getUserBackpack(profileMail, authToken).then( - // (backpackData: MyBadge[]) => { - // return getUniqueActiveBadges(backpackData); - // }, - // ); - - // const allBadges = await getAllBadges(authToken).then((allBadges) => { - // return allBadges.result as BadgeClass[]; - // }); - - // Return the fetched data as JSON - return { - // userBackpack: backpackData || [], - // allBadges: allBadges, - profile: profile, - requestingUserId: requestingUserId, - sensorsCount: sensorsCount, - measurementsCount: measurementsCount, - } + } + + return { + profile, + requestingUserId, + sensorsCount, + measurementsCount, } } - // If the user data couldn't be fetched, return an empty JSON response return { - // userBackpack: [], - // allBadges: [], profile: null, - requestingUserId: requestingUserId, + requestingUserId, sensorsCount, measurementsCount, } } -export default function () { - // Get the data from the loader function using the useLoaderData hook +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await getUserId(request) + if (!userId) return redirect('/') + + const username = params.username + if (!username) { + return { + success: false, + error: 'Missing username.', + } satisfies ActionData + } + + const profile = await getProfileByUsername(username) + if (!profile || profile.userId !== userId) { + return { + success: false, + error: 'You can only claim a device from your own profile page.', + } satisfies ActionData + } + + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + const token = formData.get('token')?.toString().trim() + + if (intent !== 'claim-device') { + return { + success: false, + error: 'Unknown action.', + } satisfies ActionData + } + + if (!token) { + return { + success: false, + error: 'Please enter a transfer token.', + } satisfies ActionData + } + + try { + const result = await claimBox(userId, token) + + return { + success: true, + message: result.message, + claimedBoxId: result.boxId, + } satisfies ActionData + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to claim device.' + + return { + success: false, + error: message, + } satisfies ActionData + } +} + +export default function ProfilePage() { const { profile, sensorsCount, measurementsCount, requestingUserId } = useLoaderData() + const actionData = useActionData() + const navigation = useNavigation() + const { t } = useTranslation('profile') const columnsTranslation = useTranslation('data-table') const isOwner = !!profile?.userId && requestingUserId === profile.userId - - // const sortedBadges = sortBadges(allBadges, userBackpack); + const isSubmitting = navigation.state === 'submitting' return (
@@ -116,6 +164,7 @@ export default function () {

+
@@ -141,89 +190,66 @@ export default function () { {t('measurements')}
- {/*
- - {userBackpack.length} - - - {t("badges")} - -
*/}
+
- {/*
+
- Badges + {t('devices')}
-
-
- {sortedBadges.map((badge: BadgeClass) => { - return ( - -
{ - return ( - obj !== null && - obj.badgeclass === badge.entityId && - !obj.revoked - ) - }) - ? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300' - : 'bg-gray-100 dark:bg-dark-boxes', - )} - > - Design - - {badge.name} - -
- - ) - })} -
-
-
*/} -
+ {profile?.user?.devices && ( - <> -
- {t('devices')} -
- - + )} + + {isOwner ? ( +
+

+ {t('take_over_device')} +

+

+ {t('enter_transfer_token')} +

+ +
+ + + + + +
+ + {actionData?.error ? ( +
+ {actionData.error} +
+ ) : null} + + {actionData?.success ? ( +
+ {actionData.message ?? t('device_successfully_claimed')} +
+ ) : null} +
+ ) : null}
) -} - -export function ErrorBoundary() { - return ( -
- -
- ) -} +} \ No newline at end of file diff --git a/public/locales/de/device-transfer.json b/public/locales/de/device-transfer.json new file mode 100644 index 00000000..0516e649 --- /dev/null +++ b/public/locales/de/device-transfer.json @@ -0,0 +1,18 @@ +{ + "transfer_created": "Transfer erstellt", + "active_token": "Aktives Token", + "give_token": "Gib dieses Token dem neuen Besitzer", + "transfer_device": "Übergib dieses Gerät einem anderen Nutzer", + "1_day": "1 Tag", + "7_days": "7 Tage", + "30_days": "30 Tage", + "60_days": "60 Tage", + "90_days": "90 Tage", + "type_to_confirm": "Schreibe {{deviceName}} um zu bestätigen und erstelle ein Token für den neuen Besitzer.", + "expiration": "Gültigkeit", + "valid_until": "Gültig bis:", + "copy": "kopieren", + "copied": "kopiert", + "submit": "Ich verstehe, Token erstellen um Gerät zu übertragen.", + "submitting": "Erstelle Token..." +} \ No newline at end of file diff --git a/public/locales/de/profile.json b/public/locales/de/profile.json index 4d7a7e45..9ed08870 100644 --- a/public/locales/de/profile.json +++ b/public/locales/de/profile.json @@ -4,5 +4,10 @@ "devices": "Geräte", "sensors": "Sensoren", "measurements": "Messungen", - "badges": "Badges" + "badges": "Badges", + "take_over_device": "Gerät übernehmen", + "taking_over": "Übernehme...", + "device_successfully_claimed": "Gerät erfolgreich übernommen.", + "enter_transfer_token": "Füge hier einen Transfer-Token ein, um ein Gerät in dein Konto zu übernehmen.", + "example_token": "z.B. 89e764ecbcf8" } diff --git a/public/locales/en/device-transfer.json b/public/locales/en/device-transfer.json new file mode 100644 index 00000000..07576a35 --- /dev/null +++ b/public/locales/en/device-transfer.json @@ -0,0 +1,18 @@ +{ + "transfer_created": "Transfer created", + "active_token": "Active token", + "give_token": "Give this token to the new owner", + "transfer_device": "Transfer this device to another user!", + "1_day": "1 day", + "7_days": "7 days", + "30_days": "30 days", + "60_days": "60 days", + "90_days": "90 days", + "type_to_confirm": "Type {{deviceName}} to confirm, then create a transfer token for the new owner.", + "expiration": "Expiration", + "valid_until": "Valid until:", + "copy": "copy", + "copied": "copied", + "submit": "I understand, transfer this device.", + "submitting": "Creating Token..." +} \ No newline at end of file diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 10b00676..98624240 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -4,5 +4,10 @@ "devices": "Devices", "sensors": "Sensors", "measurements": "Measurements", - "badges": "Badges" + "badges": "Badges", + "take_over_device": "Take over device", + "taking_over": "Taking over...", + "device_successfully_claimed": "Device successfully claimed.", + "enter_transfer_token": "Enter a transfer token here to add a device to your account.", + "example_token": "e.g. 89e764ecbcf8" }