Skip to content

Access limit feature #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: CI CD - Whim
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
Expand All @@ -26,6 +28,7 @@ jobs:
name: CD - Whim Deploy
runs-on: ubuntu-22.04
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

steps:
- name: Setup SSH key
Expand Down
144 changes: 59 additions & 85 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/components/landing/landing-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Card, CardContent } from "~/components/ui/card";

export function LandingHero() {
return (
<div className="space-y-6">
<div className="space-y-9">
{/* Brand Badge with Whim */}
<div className="inline-flex items-center gap-2 bg-indigo-50 dark:bg-indigo-900/30 px-3 py-1.5 rounded-full text-indigo-700 dark:text-indigo-300 font-medium text-sm border border-indigo-100 dark:border-indigo-800">
<Flame className="w-4 h-4" />
Expand Down Expand Up @@ -61,7 +61,7 @@ export function LandingHero() {
Self-Destruct
</div>
<div className="text-xs sm:text-xs text-slate-500 dark:text-slate-400 mt-0.5 sm:mt-1">
Deleted after reading
Deleted after reading (once or multiple times)
</div>
</div>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/components/landing/landing-process-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ export function LandingProcessFlow() {
<div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
<h3 className="font-semibold text-sm text-slate-900 dark:text-slate-100">
One-Time Access
Limited Access
</h3>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">
The recipient opens the link, enters the OTP, and views your
secret once.
secret for the allowed number of times.
</p>
</div>
</div>
Expand All @@ -111,8 +111,8 @@ export function LandingProcessFlow() {
</h3>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">
The secret is permanently deleted from our servers immediately
after being viewed.
The secret is permanently deleted from our servers after all
allowed accesses are used.
</p>
</div>
</div>
Expand Down
57 changes: 48 additions & 9 deletions src/components/landing/landing-secret-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";

import { useNewWhim } from "~/hooks/use-new-whim";

export function LandingSecretForm() {
const { mutate: newWhim, isPending } = useNewWhim();
const [message, setMessage] = useState("");
const [maxAttempts, setMaxAttempts] = useState(1);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [whimResult, setWhimResult] = useState<{
Expand All @@ -62,12 +64,13 @@ export function LandingSecretForm() {
setIsConfirmDialogOpen(false);

newWhim(
{ message },
{ message, maxAttempts },
{
onSuccess: (data: { id: string; otp: string }) => {
setWhimResult(data);
setIsDialogOpen(true);
setMessage(""); // Clear the form
setMaxAttempts(1); // Reset to default
},
}
);
Expand Down Expand Up @@ -120,14 +123,50 @@ export function LandingSecretForm() {
/>
</div>

{/* Max Attempts Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
Access Limit
</label>
<div className="grid grid-cols-5 gap-2">
{[1, 2, 3, 5, 10].map(num => (
<Button
key={num}
type="button"
variant={maxAttempts === num ? "default" : "outline"}
size="sm"
onClick={() => setMaxAttempts(num)}
disabled={isPending}
className={`text-xs ${
maxAttempts === num
? "bg-indigo-600 hover:bg-indigo-700 text-white"
: "hover:bg-slate-50 dark:hover:bg-slate-700"
}`}
>
{num}
</Button>
))}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
Number of times this whim can be accessed before
self-destructing
</p>
</div>

<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div className="flex items-start gap-2">
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">One-Time Access</p>
<p className="font-medium mb-1">
{maxAttempts === 1
? "One-Time Access"
: `${maxAttempts}-Time Access`}
</p>
<p className="text-blue-700 dark:text-blue-300">
Your secret will be permanently destroyed the moment someone
opens the link, even if it hasn't reached the expiry time.
Your secret will be permanently destroyed after being
accessed{" "}
{maxAttempts === 1 ? "once" : `${maxAttempts} times`}, even
if it hasn't reached the expiry time.
</p>
</div>
</div>
Expand Down Expand Up @@ -164,10 +203,10 @@ export function LandingSecretForm() {
</Tooltip>
</CardContent>

<CardFooter className="justify-center pt-3 pb-5 text-slate-500 dark:text-slate-400">
<CardFooter className="justify-center -mb-2 -mt-3 text-slate-500 dark:text-slate-400">
<Shield className="size-3 mr-1" />
<p className="text-sm">
Your whim is encrypted • OTP protected • One-time access
Your whim is encrypted • OTP protected • Limited access
</p>
</CardFooter>
</Card>
Expand All @@ -185,9 +224,9 @@ export function LandingSecretForm() {
</AlertDialogTitle>
<AlertDialogDescription className="text-left">
You're about to create a whim with the following message. This
secret will be encrypted and will{" "}
<strong>self-destruct immediately</strong> after being viewed
once.
secret will be encrypted and will <strong>self-destruct</strong>{" "}
after being accessed{" "}
{maxAttempts === 1 ? "once" : `${maxAttempts} times`}.
</AlertDialogDescription>
</AlertDialogHeader>

Expand Down
23 changes: 17 additions & 6 deletions src/hooks/use-get-whim.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { getWhim } from "~/server/get-whim";
import { deleteWhim } from "~/server/delete-whim";
import { recordSuccessfulAccess } from "~/server/record-successful-access";
import { incrementFailedAttempts } from "~/server/increment-failed-attempts";
import { decryptWhim } from "~/lib/crypto-utils";

Expand Down Expand Up @@ -33,15 +33,26 @@ export function useGetWhim(id: string, otp: string) {
);

try {
await deleteWhim({ data: { id: whimId } });
return { message: decryptedMessage, deletionFailed: false };
} catch (deleteError) {
console.error("Failed to delete whim:", deleteError);
const accessResult = await recordSuccessfulAccess({
data: { id: whimId },
});
return {
message: decryptedMessage,
deletionFailed: false,
deleted: accessResult.deleted,
remainingAttempts: accessResult.remainingAttempts,
maxAttempts: encryptedWhim.maxAttempts,
};
} catch (accessError) {
console.error("Failed to record successful access:", accessError);

return {
message: decryptedMessage,
deletionFailed: true,
warning: "Secret was decrypted but may still exist on server",
deleted: false,
remainingAttempts: encryptedWhim.remainingAttempts,
maxAttempts: encryptedWhim.maxAttempts,
warning: "Secret was decrypted but access tracking failed",
};
}
} catch (decryptError) {
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/use-new-whim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { newWhim } from "~/server/new-whim";

export function useNewWhim() {
return useMutation({
async mutationFn({ message }: { message: string }) {
async mutationFn({
message,
maxAttempts = 1,
}: {
message: string;
maxAttempts?: number;
}) {
const otp = generateOtp();
const encryptedWhim = await encryptWhim(message, otp);

Expand All @@ -13,6 +19,7 @@ export function useNewWhim() {
encryptedMessage: Array.from(encryptedWhim.encryptedMessage),
salt: Array.from(encryptedWhim.salt),
iv: Array.from(encryptedWhim.iv),
maxAttempts,
},
});

Expand Down
2 changes: 2 additions & 0 deletions src/lib/db/migrations/0005_groovy_hex.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `attempts` ADD `successful_attempts` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `whims` ADD `max_attempts` integer DEFAULT 1 NOT NULL;
Loading