Skip to content
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

Add org-level secrets key #7

Merged
merged 5 commits into from
Aug 2, 2024
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
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;
Loading