Skip to content

Commit 0d40a4e

Browse files
authored
Merge pull request #7 from diggerhq/feat/org-public-key
Add org-level secrets key
2 parents cd9e64e + a27a334 commit 0d40a4e

File tree

6 files changed

+328
-0
lines changed

6 files changed

+328
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// SecretsKeyManager.tsx
2+
'use client';
3+
4+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
5+
import { Button } from '@/components/ui/button';
6+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
7+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
8+
import { Input } from '@/components/ui/input';
9+
import { Label } from '@/components/ui/label';
10+
import { motion } from 'framer-motion';
11+
import { Copy, Trash2 } from 'lucide-react';
12+
import { useState } from 'react';
13+
import { toast } from 'sonner';
14+
15+
interface SecretsKeyManagerProps {
16+
publicKey: string | null;
17+
onCreateKeyPair: () => Promise<{ publicKey: string; privateKey: string }>;
18+
onDeletePublicKey: () => Promise<void>;
19+
}
20+
21+
export function SecretsKeyManager({ publicKey: initialPublicKey, onCreateKeyPair, onDeletePublicKey }: SecretsKeyManagerProps) {
22+
const [publicKey, setPublicKey] = useState<string | null>(initialPublicKey);
23+
const [privateKey, setPrivateKey] = useState<string | null>(null);
24+
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useState(false);
25+
const [isCreating, setIsCreating] = useState(false);
26+
const [isDeleting, setIsDeleting] = useState(false);
27+
28+
const handleCreateKeyPair = async () => {
29+
setIsCreating(true);
30+
try {
31+
const { publicKey: newPublicKey, privateKey: newPrivateKey } = await onCreateKeyPair();
32+
setPublicKey(newPublicKey);
33+
setPrivateKey(newPrivateKey);
34+
setIsPrivateKeyCopied(false);
35+
} catch (error) {
36+
console.error('Failed to create key pair:', error);
37+
toast.error('Failed to create key pair. Please try again.');
38+
} finally {
39+
setIsCreating(false);
40+
}
41+
};
42+
43+
const handleDeletePublicKey = async () => {
44+
setIsDeleting(true);
45+
try {
46+
await onDeletePublicKey();
47+
setPublicKey(null);
48+
setPrivateKey(null);
49+
setIsPrivateKeyCopied(false);
50+
toast.success('Public key has been deleted.');
51+
} catch (error) {
52+
console.error('Failed to delete public key:', error);
53+
toast.error('Failed to delete public key. Please try again.');
54+
} finally {
55+
setIsDeleting(false);
56+
}
57+
};
58+
59+
const copyPrivateKeyToClipboard = () => {
60+
if (privateKey) {
61+
navigator.clipboard.writeText(privateKey);
62+
toast.success('The private key has been copied to your clipboard.');
63+
setIsPrivateKeyCopied(true);
64+
}
65+
};
66+
67+
return (
68+
<motion.div
69+
initial={{ opacity: 0, y: 20 }}
70+
animate={{ opacity: 1, y: 0 }}
71+
transition={{ duration: 0.5 }}
72+
>
73+
<Card>
74+
<CardHeader>
75+
<CardTitle className="flex items-center space-x-2">
76+
<span>Secrets Key</span>
77+
</CardTitle>
78+
<CardDescription>
79+
Public key for encrypting sensitive variables
80+
</CardDescription>
81+
</CardHeader>
82+
<CardContent>
83+
{publicKey ? (
84+
<div className="space-y-4">
85+
<div>
86+
<Label>Public Key</Label>
87+
<div className="flex items-center mt-1">
88+
<Input
89+
readOnly
90+
value={publicKey}
91+
className="font-mono text-sm"
92+
/>
93+
<Button
94+
variant="outline"
95+
size="icon"
96+
className="ml-2"
97+
onClick={() => {
98+
navigator.clipboard.writeText(publicKey);
99+
toast.success('Public key copied to clipboard.');
100+
}}
101+
>
102+
<Copy className="h-4 w-4" />
103+
</Button>
104+
</div>
105+
</div>
106+
{privateKey && (
107+
<Alert className='bg-muted/50'>
108+
<AlertTitle>Private Key (ONLY SHOWN ONCE)</AlertTitle>
109+
<AlertDescription>
110+
<p className="mb-2">Save this in your GitHub Action Secrets (org level):</p>
111+
<div className="flex items-center">
112+
<Input
113+
readOnly
114+
value={isPrivateKeyCopied ? '•'.repeat(100) : privateKey}
115+
className="font-mono text-sm"
116+
/>
117+
{!isPrivateKeyCopied && (
118+
<Button
119+
variant="outline"
120+
size="icon"
121+
className="ml-2"
122+
onClick={copyPrivateKeyToClipboard}
123+
>
124+
<Copy className="h-4 w-4" />
125+
</Button>
126+
)}
127+
</div>
128+
</AlertDescription>
129+
</Alert>
130+
)}
131+
</div>
132+
) : (
133+
<Button onClick={handleCreateKeyPair} disabled={isCreating}>
134+
{isCreating ? 'Creating...' : 'Create Secrets Key'}
135+
</Button>
136+
)}
137+
</CardContent>
138+
{publicKey && (
139+
<CardFooter>
140+
<Dialog>
141+
<DialogTrigger asChild>
142+
<Button variant="destructive">
143+
<Trash2 className="mr-2 h-4 w-4" />
144+
Delete Secrets Key
145+
</Button>
146+
</DialogTrigger>
147+
<DialogContent>
148+
<DialogHeader>
149+
<DialogTitle>Are you absolutely sure?</DialogTitle>
150+
<DialogDescription>
151+
This action cannot be undone. You will lose all your secrets without the possibility to recover them.
152+
</DialogDescription>
153+
</DialogHeader>
154+
<DialogFooter>
155+
<Button variant="outline" onClick={() => { }}>Cancel</Button>
156+
<Button variant="destructive" onClick={handleDeletePublicKey} disabled={isDeleting}>
157+
{isDeleting ? 'Deleting...' : 'Delete'}
158+
</Button>
159+
</DialogFooter>
160+
</DialogContent>
161+
</Dialog>
162+
</CardFooter>
163+
)}
164+
</Card>
165+
</motion.div>
166+
);
167+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use server';
2+
3+
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
4+
import { createKeyPair, deletePublicKey, getPublicKey } from '@/data/user/secretKey';
5+
import { SecretsKeyManager } from './SecretKeyManager';
6+
7+
const publicKey: string = 'asdfasdf'; //TODO state, fetch
8+
const privateKey: string = 'asdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaa'; //TODO state
9+
10+
function Wrapper({ children }: { children: React.ReactNode }) {
11+
return (
12+
<Card className="w-full max-w-5xl ">
13+
<CardHeader className="space-y-1">
14+
<CardTitle className="flex items-center space-x-2">
15+
Secrets Key
16+
</CardTitle>
17+
<CardDescription>
18+
Public key for encrypting sensitive variables
19+
</CardDescription>
20+
</CardHeader>
21+
<CardFooter className='justify-start'>
22+
{children}
23+
</CardFooter>
24+
</Card>
25+
);
26+
}
27+
28+
export async function SetSecretsKey({ organizationId }: { organizationId: string }) {
29+
const publicKey = await getPublicKey(organizationId);
30+
return (
31+
<SecretsKeyManager
32+
publicKey={publicKey}
33+
onCreateKeyPair={async () => {
34+
'use server';
35+
const result = await createKeyPair(organizationId);
36+
if (result.status === 'error') {
37+
throw new Error(result.message);
38+
}
39+
return result.data;
40+
}}
41+
onDeletePublicKey={async () => {
42+
'use server';
43+
const result = await deletePublicKey(organizationId);
44+
if (result.status === 'error') {
45+
throw new Error(result.message);
46+
}
47+
}}
48+
/>
49+
);
50+
}

src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/settings/page.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Suspense } from "react";
99
import { DeleteOrganization } from "./DeleteOrganization";
1010
import { EditOrganizationForm } from "./EditOrganizationForm";
1111
import { SetDefaultOrganizationPreference } from "./SetDefaultOrganizationPreference";
12+
import { SetSecretsKey } from "./SetSecretsKey";
1213
import { SettingsFormSkeleton } from "./SettingsSkeletons";
1314

1415
async function EditOrganization({
@@ -69,6 +70,9 @@ export default async function EditOrganizationPage({
6970
<Suspense fallback={<SettingsFormSkeleton />}>
7071
<EditOrganization organizationId={organizationId} />
7172
</Suspense>
73+
<Suspense fallback={<SettingsFormSkeleton />}>
74+
<SetSecretsKey organizationId={organizationId} />
75+
</Suspense>
7276
<Suspense fallback={<SettingsFormSkeleton />}>
7377
<SetDefaultOrganizationPreference organizationId={organizationId} />
7478
</Suspense>

src/data/user/secretKey.ts

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// data/user/secretsKey.ts
2+
'use server';
3+
4+
import { createSupabaseUserServerActionClient } from '@/supabase-clients/user/createSupabaseUserServerActionClient';
5+
import { createSupabaseUserServerComponentClient } from '@/supabase-clients/user/createSupabaseUserServerComponentClient';
6+
import { SAPayload } from '@/types';
7+
import crypto from 'crypto';
8+
import { revalidatePath } from 'next/cache';
9+
10+
export async function getPublicKey(
11+
organizationId: string,
12+
): Promise<string | null> {
13+
const supabase = createSupabaseUserServerComponentClient();
14+
const { data, error } = await supabase
15+
.from('organizations')
16+
.select('public_key')
17+
.eq('id', organizationId)
18+
.single();
19+
20+
if (error) {
21+
console.error('Error fetching public key:', error);
22+
return null;
23+
}
24+
25+
return data?.public_key || null;
26+
}
27+
28+
export async function createKeyPair(
29+
organizationId: string,
30+
): Promise<SAPayload<{ publicKey: string; privateKey: string }>> {
31+
const supabase = createSupabaseUserServerActionClient();
32+
33+
try {
34+
// Generate RSA key pair
35+
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
36+
modulusLength: 2048,
37+
publicKeyEncoding: {
38+
type: 'spki',
39+
format: 'pem',
40+
},
41+
privateKeyEncoding: {
42+
type: 'pkcs8',
43+
format: 'pem',
44+
},
45+
});
46+
47+
// Save public key to the database
48+
const { error } = await supabase
49+
.from('organizations')
50+
.update({ public_key: publicKey })
51+
.eq('id', organizationId);
52+
53+
if (error) throw error;
54+
55+
revalidatePath(`/org/${organizationId}/settings`);
56+
57+
return {
58+
status: 'success',
59+
data: { publicKey, privateKey },
60+
};
61+
} catch (error) {
62+
console.error('Error creating key pair:', error);
63+
return {
64+
status: 'error',
65+
message: 'Failed to create key pair',
66+
};
67+
}
68+
}
69+
70+
export async function deletePublicKey(
71+
organizationId: string,
72+
): Promise<SAPayload> {
73+
const supabase = createSupabaseUserServerActionClient();
74+
75+
try {
76+
const { error } = await supabase
77+
.from('organizations')
78+
.update({ public_key: null })
79+
.eq('id', organizationId);
80+
81+
if (error) throw error;
82+
83+
revalidatePath(`/org/${organizationId}/settings`);
84+
85+
return {
86+
status: 'success',
87+
};
88+
} catch (error) {
89+
console.error('Error deleting public key:', error);
90+
return {
91+
status: 'error',
92+
message: 'Failed to delete public key',
93+
};
94+
}
95+
}

src/lib/database.types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -1085,18 +1085,21 @@ export type Database = {
10851085
Row: {
10861086
created_at: string
10871087
id: string
1088+
public_key: string | null
10881089
slug: string
10891090
title: string
10901091
}
10911092
Insert: {
10921093
created_at?: string
10931094
id?: string
1095+
public_key?: string | null
10941096
slug?: string
10951097
title?: string
10961098
}
10971099
Update: {
10981100
created_at?: string
10991101
id?: string
1102+
public_key?: string | null
11001103
slug?: string
11011104
title?: string
11021105
}
@@ -1337,6 +1340,13 @@ export type Database = {
13371340
referencedRelation: "repos"
13381341
referencedColumns: ["id"]
13391342
},
1343+
{
1344+
foreignKeyName: "projects_team_id_fkey"
1345+
columns: ["team_id"]
1346+
isOneToOne: false
1347+
referencedRelation: "teams"
1348+
referencedColumns: ["id"]
1349+
},
13401350
]
13411351
}
13421352
repos: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE organizations
2+
ADD COLUMN public_key TEXT;

0 commit comments

Comments
 (0)