Skip to content

Commit

Permalink
Merge pull request #7 from diggerhq/feat/org-public-key
Browse files Browse the repository at this point in the history
Add org-level secrets key
  • Loading branch information
ZIJ authored Aug 2, 2024
2 parents cd9e64e + a27a334 commit 0d40a4e
Show file tree
Hide file tree
Showing 6 changed files with 328 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// SecretsKeyManager.tsx
'use client';

import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { motion } from 'framer-motion';
import { Copy, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';

interface SecretsKeyManagerProps {
publicKey: string | null;
onCreateKeyPair: () => Promise<{ publicKey: string; privateKey: string }>;
onDeletePublicKey: () => Promise<void>;
}

export function SecretsKeyManager({ publicKey: initialPublicKey, onCreateKeyPair, onDeletePublicKey }: SecretsKeyManagerProps) {
const [publicKey, setPublicKey] = useState<string | null>(initialPublicKey);
const [privateKey, setPrivateKey] = useState<string | null>(null);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);

const handleCreateKeyPair = async () => {
setIsCreating(true);
try {
const { publicKey: newPublicKey, privateKey: newPrivateKey } = await onCreateKeyPair();
setPublicKey(newPublicKey);
setPrivateKey(newPrivateKey);
setIsPrivateKeyCopied(false);
} catch (error) {
console.error('Failed to create key pair:', error);
toast.error('Failed to create key pair. Please try again.');
} finally {
setIsCreating(false);
}
};

const handleDeletePublicKey = async () => {
setIsDeleting(true);
try {
await onDeletePublicKey();
setPublicKey(null);
setPrivateKey(null);
setIsPrivateKeyCopied(false);
toast.success('Public key has been deleted.');
} catch (error) {
console.error('Failed to delete public key:', error);
toast.error('Failed to delete public key. Please try again.');
} finally {
setIsDeleting(false);
}
};

const copyPrivateKeyToClipboard = () => {
if (privateKey) {
navigator.clipboard.writeText(privateKey);
toast.success('The private key has been copied to your clipboard.');
setIsPrivateKeyCopied(true);
}
};

return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<span>Secrets Key</span>
</CardTitle>
<CardDescription>
Public key for encrypting sensitive variables
</CardDescription>
</CardHeader>
<CardContent>
{publicKey ? (
<div className="space-y-4">
<div>
<Label>Public Key</Label>
<div className="flex items-center mt-1">
<Input
readOnly
value={publicKey}
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
className="ml-2"
onClick={() => {
navigator.clipboard.writeText(publicKey);
toast.success('Public key copied to clipboard.');
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
{privateKey && (
<Alert className='bg-muted/50'>
<AlertTitle>Private Key (ONLY SHOWN ONCE)</AlertTitle>
<AlertDescription>
<p className="mb-2">Save this in your GitHub Action Secrets (org level):</p>
<div className="flex items-center">
<Input
readOnly
value={isPrivateKeyCopied ? '•'.repeat(100) : privateKey}
className="font-mono text-sm"
/>
{!isPrivateKeyCopied && (
<Button
variant="outline"
size="icon"
className="ml-2"
onClick={copyPrivateKeyToClipboard}
>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
</AlertDescription>
</Alert>
)}
</div>
) : (
<Button onClick={handleCreateKeyPair} disabled={isCreating}>
{isCreating ? 'Creating...' : 'Create Secrets Key'}
</Button>
)}
</CardContent>
{publicKey && (
<CardFooter>
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Secrets Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. You will lose all your secrets without the possibility to recover them.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => { }}>Cancel</Button>
<Button variant="destructive" onClick={handleDeletePublicKey} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardFooter>
)}
</Card>
</motion.div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use server';

import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { createKeyPair, deletePublicKey, getPublicKey } from '@/data/user/secretKey';
import { SecretsKeyManager } from './SecretKeyManager';

const publicKey: string = 'asdfasdf'; //TODO state, fetch
const privateKey: string = 'asdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaaasdfaa'; //TODO state

function Wrapper({ children }: { children: React.ReactNode }) {
return (
<Card className="w-full max-w-5xl ">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center space-x-2">
Secrets Key
</CardTitle>
<CardDescription>
Public key for encrypting sensitive variables
</CardDescription>
</CardHeader>
<CardFooter className='justify-start'>
{children}
</CardFooter>
</Card>
);
}

export async function SetSecretsKey({ organizationId }: { organizationId: string }) {
const publicKey = await getPublicKey(organizationId);
return (
<SecretsKeyManager
publicKey={publicKey}
onCreateKeyPair={async () => {
'use server';
const result = await createKeyPair(organizationId);
if (result.status === 'error') {
throw new Error(result.message);
}
return result.data;
}}
onDeletePublicKey={async () => {
'use server';
const result = await deletePublicKey(organizationId);
if (result.status === 'error') {
throw new Error(result.message);
}
}}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Suspense } from "react";
import { DeleteOrganization } from "./DeleteOrganization";
import { EditOrganizationForm } from "./EditOrganizationForm";
import { SetDefaultOrganizationPreference } from "./SetDefaultOrganizationPreference";
import { SetSecretsKey } from "./SetSecretsKey";
import { SettingsFormSkeleton } from "./SettingsSkeletons";

async function EditOrganization({
Expand Down Expand Up @@ -69,6 +70,9 @@ export default async function EditOrganizationPage({
<Suspense fallback={<SettingsFormSkeleton />}>
<EditOrganization organizationId={organizationId} />
</Suspense>
<Suspense fallback={<SettingsFormSkeleton />}>
<SetSecretsKey organizationId={organizationId} />
</Suspense>
<Suspense fallback={<SettingsFormSkeleton />}>
<SetDefaultOrganizationPreference organizationId={organizationId} />
</Suspense>
Expand Down
95 changes: 95 additions & 0 deletions src/data/user/secretKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// data/user/secretsKey.ts
'use server';

import { createSupabaseUserServerActionClient } from '@/supabase-clients/user/createSupabaseUserServerActionClient';
import { createSupabaseUserServerComponentClient } from '@/supabase-clients/user/createSupabaseUserServerComponentClient';
import { SAPayload } from '@/types';
import crypto from 'crypto';
import { revalidatePath } from 'next/cache';

export async function getPublicKey(
organizationId: string,
): Promise<string | null> {
const supabase = createSupabaseUserServerComponentClient();
const { data, error } = await supabase
.from('organizations')
.select('public_key')
.eq('id', organizationId)
.single();

if (error) {
console.error('Error fetching public key:', error);
return null;
}

return data?.public_key || null;
}

export async function createKeyPair(
organizationId: string,
): Promise<SAPayload<{ publicKey: string; privateKey: string }>> {
const supabase = createSupabaseUserServerActionClient();

try {
// Generate RSA key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});

// Save public key to the database
const { error } = await supabase
.from('organizations')
.update({ public_key: publicKey })
.eq('id', organizationId);

if (error) throw error;

revalidatePath(`/org/${organizationId}/settings`);

return {
status: 'success',
data: { publicKey, privateKey },
};
} catch (error) {
console.error('Error creating key pair:', error);
return {
status: 'error',
message: 'Failed to create key pair',
};
}
}

export async function deletePublicKey(
organizationId: string,
): Promise<SAPayload> {
const supabase = createSupabaseUserServerActionClient();

try {
const { error } = await supabase
.from('organizations')
.update({ public_key: null })
.eq('id', organizationId);

if (error) throw error;

revalidatePath(`/org/${organizationId}/settings`);

return {
status: 'success',
};
} catch (error) {
console.error('Error deleting public key:', error);
return {
status: 'error',
message: 'Failed to delete public key',
};
}
}
10 changes: 10 additions & 0 deletions src/lib/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,18 +1085,21 @@ export type Database = {
Row: {
created_at: string
id: string
public_key: string | null
slug: string
title: string
}
Insert: {
created_at?: string
id?: string
public_key?: string | null
slug?: string
title?: string
}
Update: {
created_at?: string
id?: string
public_key?: string | null
slug?: string
title?: string
}
Expand Down Expand Up @@ -1337,6 +1340,13 @@ export type Database = {
referencedRelation: "repos"
referencedColumns: ["id"]
},
{
foreignKeyName: "projects_team_id_fkey"
columns: ["team_id"]
isOneToOne: false
referencedRelation: "teams"
referencedColumns: ["id"]
},
]
}
repos: {
Expand Down
2 changes: 2 additions & 0 deletions supabase/migrations/20240802102519_public_key_in_org.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE organizations
ADD COLUMN public_key TEXT;

0 comments on commit 0d40a4e

Please sign in to comment.