added credentials

This commit is contained in:
Rajpreet0 2026-09-28 20:17:33 +02:00
parent f218d472fa
commit 59f209d754
22 changed files with 741 additions and 0 deletions

View file

@ -0,0 +1,10 @@
import PasswordAdminViewPage from "@/modules/admin/ui/views/password-admin-view"
type Params = Promise<{id: string}>
const PasswordAdminPage = async ({ params } : { params: Params }) => {
const { id } = await params
return <PasswordAdminViewPage clientProfileId={id}/>
}
export default PasswordAdminPage

View file

@ -0,0 +1,7 @@
import PasswordClientViewPage from "@/modules/client/ui/views/password-client-view"
const PasswordPage = () => {
return <PasswordClientViewPage/>
}
export default PasswordPage

View file

@ -0,0 +1,39 @@
-- Clients dürfen eigene Credentials anlegen und bearbeiten (nicht löschen).
-- Löschen bleibt exklusiv Admin (siehe RLS-Policy credentials_admin_delete).
create or replace function public.create_credential(
p_client_id uuid, p_service text, p_username text, p_password text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare v_secret uuid; v_id uuid;
begin
if not (public.is_admin() or p_client_id = public.current_client_id()) then
raise exception 'forbidden' using errcode = '42501';
end if;
v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service);
insert into public.credentials (client_id, service, username, vault_secret_id, updated_by)
values (p_client_id, p_service, p_username, v_secret, auth.uid())
returning id into v_id;
return v_id;
end $$;
-- p_password = NULL -> Passwort bleibt unverändert
create or replace function public.update_credential(
p_id uuid, p_service text, p_username text, p_password text default null
) returns void language plpgsql security definer set search_path = '' as $$
declare v_secret uuid; v_client uuid;
begin
select client_id, vault_secret_id into v_client, v_secret
from public.credentials where id = p_id;
if not found then raise exception 'credential not found'; end if;
if not (public.is_admin() or v_client = public.current_client_id()) then
raise exception 'forbidden' using errcode = '42501';
end if;
update public.credentials
set service = p_service, username = p_username, updated_by = auth.uid()
where id = p_id;
if p_password is not null and p_password <> '' then
perform vault.update_secret(v_secret, p_password);
end if;
end $$;

View file

@ -0,0 +1,27 @@
import { getCredentials } from "@/modules/shared/queries/credentials"
import { CredentialsTable } from "@/modules/shared/ui/components/credentials-table"
import { CredentialDialog } from "@/modules/shared/ui/components/credential-dialog"
import { createAdminClient } from "@/lib/supabase/admin"
const PasswordAdminViewPage = async ({ clientProfileId }: { clientProfileId: string }) => {
const supabase = createAdminClient()
const { data: profile } = await supabase
.from("profiles")
.select("client_id")
.eq("id", clientProfileId)
.single()
const credentials = profile?.client_id ? await getCredentials(profile.client_id) : []
return (
<div className="flex flex-col gap-8 w-full">
<div className="w-full flex flex-row justify-between items-center">
<h1 className="text-3xl font-semibold">Passwörter</h1>
<CredentialDialog profileId={clientProfileId} />
</div>
<CredentialsTable profileId={clientProfileId} credentials={credentials} isAdmin />
</div>
)
}
export default PasswordAdminViewPage

View file

@ -0,0 +1,30 @@
import { verifySession } from "@/lib/supabase/dal"
import { createAdminClient } from "@/lib/supabase/admin"
import { getCredentials } from "@/modules/shared/queries/credentials"
import { CredentialsTable } from "@/modules/shared/ui/components/credentials-table"
import { CredentialDialog } from "@/modules/shared/ui/components/credential-dialog"
const PasswordClientViewPage = async () => {
const session = await verifySession()
const supabase = createAdminClient()
const { data: profile } = await supabase
.from("profiles")
.select("client_id")
.eq("id", session.userId)
.single()
const credentials = profile?.client_id ? await getCredentials(profile.client_id) : []
return (
<div className="flex flex-col gap-8 w-full">
<div className="w-full flex flex-row justify-between items-center">
<h1 className="text-3xl font-semibold">Passwörter</h1>
<CredentialDialog profileId={session.userId} />
</div>
<CredentialsTable profileId={session.userId} credentials={credentials} isAdmin={false} />
</div>
)
}
export default PasswordClientViewPage

View file

@ -0,0 +1,153 @@
"use server";
import * as z from "zod";
import { revalidatePath } from "next/cache";
import { createAdminClient } from "@/lib/supabase/admin";
import { verifySession } from "@/lib/supabase/dal";
async function resolveClientId(profileId: string) {
const supabase = createAdminClient();
const { data: profile, error } = await supabase
.from("profiles")
.select("client_id")
.eq("id", profileId)
.single();
if (error || !profile?.client_id) return null;
return profile.client_id as string;
}
const CredentialSchema = z.object({
profileId: z.string().uuid(),
service: z.string().min(1, { error: "Dienst ist erforderlich." }),
username: z.string().min(1, { error: "Benutzername ist erforderlich." }),
password: z.string().min(1, { error: "Passwort ist erforderlich." }),
});
export type CredentialState = { error: string } | { success: true } | undefined;
export async function createCredential(
_prevState: CredentialState,
formData: FormData
): Promise<CredentialState> {
const session = await verifySession();
const profileId = String(formData.get("profileId") ?? "");
if (!session.isAdmin && session.userId !== profileId) {
return { error: "Keine Berechtigung." };
}
const validatedFields = CredentialSchema.safeParse({
profileId,
service: formData.get("service"),
username: formData.get("username"),
password: formData.get("password"),
});
if (!validatedFields.success) {
const { fieldErrors } = z.flattenError(validatedFields.error);
const firstError = Object.values(fieldErrors).flat()[0];
return { error: firstError ?? "Bitte alle Felder ausfüllen." };
}
const clientId = await resolveClientId(profileId);
if (!clientId) {
return { error: "Client hat noch keine Stammdaten hinterlegt." };
}
const supabase = createAdminClient();
const { error } = await supabase.rpc("create_credential", {
p_client_id: clientId,
p_service: validatedFields.data.service,
p_username: validatedFields.data.username,
p_password: validatedFields.data.password,
});
if (error) return { error: "Eintrag konnte nicht gespeichert werden." };
revalidatePath("/client/passwords");
revalidatePath(`/admin/clients/${profileId}/passwords`);
return { success: true };
}
const UpdateCredentialSchema = z.object({
profileId: z.string().uuid(),
credentialId: z.string().uuid(),
service: z.string().min(1, { error: "Dienst ist erforderlich." }),
username: z.string().min(1, { error: "Benutzername ist erforderlich." }),
password: z.string().optional(),
});
export async function updateCredential(
_prevState: CredentialState,
formData: FormData
): Promise<CredentialState> {
const session = await verifySession();
const profileId = String(formData.get("profileId") ?? "");
if (!session.isAdmin && session.userId !== profileId) {
return { error: "Keine Berechtigung." };
}
const password = String(formData.get("password") ?? "").trim();
const validatedFields = UpdateCredentialSchema.safeParse({
profileId,
credentialId: formData.get("credentialId"),
service: formData.get("service"),
username: formData.get("username"),
password: password === "" ? undefined : password,
});
if (!validatedFields.success) {
const { fieldErrors } = z.flattenError(validatedFields.error);
const firstError = Object.values(fieldErrors).flat()[0];
return { error: firstError ?? "Bitte alle Felder ausfüllen." };
}
const supabase = createAdminClient();
const { error } = await supabase.rpc("update_credential", {
p_id: validatedFields.data.credentialId,
p_service: validatedFields.data.service,
p_username: validatedFields.data.username,
p_password: validatedFields.data.password ?? null,
});
if (error) return { error: "Eintrag konnte nicht aktualisiert werden." };
revalidatePath("/client/passwords");
revalidatePath(`/admin/clients/${profileId}/passwords`);
return { success: true };
}
export async function revealCredential(credentialId: string): Promise<{ password: string } | { error: string }> {
await verifySession();
const supabase = createAdminClient();
const { data, error } = await supabase.rpc("reveal_credential", { p_id: credentialId });
if (error || typeof data !== "string") {
return { error: "Passwort konnte nicht abgerufen werden." };
}
return { password: data };
}
export async function deleteCredential(profileId: string, credentialId: string): Promise<CredentialState> {
const session = await verifySession();
if (!session.isAdmin) {
return { error: "Keine Berechtigung." };
}
const supabase = createAdminClient();
const { error } = await supabase.from("credentials").delete().eq("id", credentialId);
if (error) return { error: "Eintrag konnte nicht gelöscht werden." };
revalidatePath(`/admin/clients/${profileId}/passwords`);
return { success: true };
}

View file

@ -0,0 +1,24 @@
export type Service = {
id: string;
label: string;
logo: string;
};
export const SERVICES: Service[] = [
{ id: "vercel", label: "Vercel", logo: "/logos/vercel.svg" },
{ id: "hostinger", label: "Hostinger", logo: "/logos/hostinger.svg" },
{ id: "hostinger-webmail", label: "Hostinger Webmail", logo: "/logos/hostinger.svg" },
{ id: "supabase", label: "Supabase", logo: "/logos/supabase.svg" },
{ id: "github", label: "GitHub", logo: "/logos/github.svg" },
{ id: "shopify", label: "Shopify", logo: "/logos/shopify.svg" },
{ id: "wordpress", label: "Wordpress", logo: "/logos/wordpress.svg" },
{ id: "google-workspace", label: "Google Workspace", logo: "/logos/google.svg" },
{ id: "google-ads", label: "Google Ads", logo: "/logos/google-ads.svg" },
{ id: "sanity", label: "Sanity", logo: "/logos/sanity.svg" },
{ id: "stripe", label: "Stripe", logo: "/logos/stripe.svg" },
{ id: "other", label: "Sonstiges", logo: "" },
];
export function getServiceById(id: string): Service {
return SERVICES.find((service) => service.id === id) ?? SERVICES[SERVICES.length - 1];
}

View file

@ -0,0 +1,16 @@
import "server-only"
import { createAdminClient } from "@/lib/supabase/admin"
export async function getCredentials(clientId: string) {
const supabase = createAdminClient()
const { data, error } = await supabase
.from("credentials")
.select("id, service, username, created_at, updated_at")
.eq("client_id", clientId)
.order("service")
if (error) throw new Error("Passwörter konnten nicht geladen werden.")
return data
}

View file

@ -0,0 +1,167 @@
"use client"
import { useActionState, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
import { createCredential, updateCredential } from "@/modules/shared/actions/credentials";
import { SERVICES } from "@/modules/shared/data/services-data";
import { ServiceLogo } from "@/modules/shared/ui/components/service-logo";
type Credential = {
id: string;
service: string;
username: string;
} | null;
const selectClassName =
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50";
function CredentialDialogContent({
profileId,
credential,
onDone,
}: {
profileId: string;
credential: Credential;
onDone: () => void;
}) {
const isEdit = Boolean(credential);
const action = isEdit ? updateCredential : createCredential;
const [serviceId, setServiceId] = useState(
credential?.service ?? SERVICES[0].id
);
async function actionWithDoneCheck(_prevState: Awaited<ReturnType<typeof action>>, formData: FormData) {
const result = await action(_prevState, formData);
if (result && "success" in result && result.success) {
onDone();
}
return result;
}
const [state, formAction, pending] = useActionState(actionWithDoneCheck, undefined);
return (
<>
<DialogHeader className="mb-4">
<DialogTitle>{isEdit ? "Eintrag bearbeiten" : "Neuer Eintrag"}</DialogTitle>
</DialogHeader>
<form action={formAction}>
<input type="hidden" name="profileId" value={profileId} />
{isEdit && <input type="hidden" name="credentialId" value={credential!.id} />}
<FieldGroup>
<Field>
<FieldLabel htmlFor="service">
Dienst <span className="text-destructive">*</span>
</FieldLabel>
<div className="flex items-center gap-2">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border border-input bg-background">
<ServiceLogo serviceId={serviceId} size={20} className="size-5 object-contain" />
</div>
<select
id="service"
name="service"
value={serviceId}
onChange={(event) => setServiceId(event.target.value)}
required
className={selectClassName}
>
{SERVICES.map((service) => (
<option key={service.id} value={service.id}>
{service.label}
</option>
))}
</select>
</div>
</Field>
<Field>
<FieldLabel htmlFor="username">
Benutzername <span className="text-destructive">*</span>
</FieldLabel>
<Input
id="username"
name="username"
defaultValue={credential?.username ?? ""}
required
className="focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel htmlFor="password">
Passwort {!isEdit && <span className="text-destructive">*</span>}
</FieldLabel>
<Input
id="password"
name="password"
type="password"
required={!isEdit}
placeholder={isEdit ? "Leer lassen, um Passwort zu behalten" : undefined}
className="focus-visible:ring-0"
/>
</Field>
{state && "error" in state && (
<p className="text-sm text-destructive">{state.error}</p>
)}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>
{pending ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</>
);
}
export function CredentialDialog({
profileId,
credential,
trigger,
}: {
profileId: string;
credential?: Credential;
trigger?: React.ReactElement;
}) {
const [open, setOpen] = useState(false);
const [instanceKey, setInstanceKey] = useState(0);
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) {
setInstanceKey((key) => key + 1);
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={trigger ?? <Button variant="outline" className="cursor-pointer" />}
>
{!trigger && (
<>
<PlusIcon className="size-4" />
Neuer Eintrag
</>
)}
</DialogTrigger>
<DialogContent>
<CredentialDialogContent
key={instanceKey}
profileId={profileId}
credential={credential ?? null}
onDone={() => handleOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,139 @@
"use client"
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { EyeIcon, EyeOffIcon, PencilIcon } from "lucide-react";
import { revealCredential } from "@/modules/shared/actions/credentials";
import { getServiceById } from "@/modules/shared/data/services-data";
import { CredentialDialog } from "./credential-dialog";
import { DeleteCredentialDialog } from "./delete-credential-dialog";
import { ServiceLogo } from "./service-logo";
type Credential = {
id: string;
service: string;
username: string;
created_at: string;
updated_at: string;
};
function RevealButton({ credentialId }: { credentialId: string }) {
const [password, setPassword] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function handleToggle() {
if (password !== null) {
setPassword(null);
return;
}
setPending(true);
const result = await revealCredential(credentialId);
setPending(false);
if ("password" in result) {
setPassword(result.password);
}
}
return (
<div className="flex items-center gap-2">
<span className="min-w-32 font-mono text-small">
{password ?? "••••••••"}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={handleToggle}
disabled={pending}
aria-label={password !== null ? "Passwort verbergen" : "Passwort anzeigen"}
>
{password !== null ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
</Button>
</div>
);
}
export function CredentialsTable({
profileId,
credentials,
isAdmin,
}: {
profileId: string;
credentials: Credential[];
isAdmin: boolean;
}) {
if (credentials.length === 0) {
return (
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
Noch keine Passwörter hinterlegt.
</p>
);
}
return (
<div className="overflow-hidden rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Dienst</TableHead>
<TableHead>Benutzername</TableHead>
<TableHead>Passwort</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{credentials.map((credential) => {
const service = getServiceById(credential.service);
return (
<TableRow key={credential.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<ServiceLogo serviceId={credential.service} size={18} className="size-4.5 object-contain" />
</div>
<span className="font-medium">{service.label}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">{credential.username}</TableCell>
<TableCell>
<RevealButton credentialId={credential.id} />
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<CredentialDialog
profileId={profileId}
credential={credential}
trigger={
<Button variant="ghost" size="icon-sm" aria-label="Bearbeiten">
<PencilIcon className="size-4" />
</Button>
}
/>
{isAdmin && (
<DeleteCredentialDialog
profileId={profileId}
credentialId={credential.id}
service={service.label}
/>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,68 @@
"use client"
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2Icon } from "lucide-react";
import { deleteCredential } from "@/modules/shared/actions/credentials";
export function DeleteCredentialDialog({
profileId,
credentialId,
service,
}: {
profileId: string;
credentialId: string;
service: string;
}) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const router = useRouter();
async function handleDelete() {
setPending(true);
const result = await deleteCredential(profileId, credentialId);
setPending(false);
if (result && "success" in result) {
setOpen(false);
router.refresh();
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={<Button variant="ghost" size="icon-sm" aria-label="Löschen" />}
>
<Trash2Icon className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-4">
<DialogTitle>Eintrag löschen</DialogTitle>
<DialogDescription>
Möchtest du den Eintrag <span className="font-medium text-foreground">{service}</span> wirklich löschen?
Diese Aktion kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
{pending ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,35 @@
"use client"
import { useState } from "react";
import Image from "next/image";
import { ServerIcon } from "lucide-react";
import { getServiceById } from "@/modules/shared/data/services-data";
export function ServiceLogo({
serviceId,
size = 20,
className,
}: {
serviceId: string;
size?: number;
className?: string;
}) {
const service = getServiceById(serviceId);
const [failed, setFailed] = useState(false);
if (!service.logo || failed) {
return <ServerIcon className={className ?? "size-4.5 text-muted-foreground"} />;
}
return (
<Image
src={service.logo}
alt={service.label}
width={size}
height={size}
className={className ?? "object-contain"}
style={{ width: size, height: size }}
onError={() => setFailed(true)}
/>
);
}

1
public/logos/github.svg Normal file
View file

@ -0,0 +1 @@
<svg width="2500" height="2432" viewBox="0 0 256 249" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet"><g fill="#161614"><path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0"/><path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398"/></g></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2442.4 2237" style="enable-background:new 0 0 2442.4 2237;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FBBC04;}
.st1{fill:#4285F4;}
.st2{fill:#34A853;}
</style>
<g>
<path class="st0" d="M50.4,1628.6L862.5,221.9l703.7,406.3L754.2,2034.9L50.4,1628.6z"/>
<path class="st1" d="M2385.1,1623.7L1572.5,216.5C1467.6,18.1,1221.8-57.7,1023.4,47.2S749.3,397.9,854.1,596.3
c4.6,8.7,9.5,17.2,14.7,25.6l812.6,1407.2c109.6,195.8,357.2,265.7,553,156.1c195.8-109.6,265.7-357.2,156.1-553
c-1.8-3.2-3.6-6.3-5.4-9.4L2385.1,1623.7L2385.1,1623.7z"/>
<circle class="st2" cx="406.3" cy="1828.6" r="406.3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 894 B

1
public/logos/google.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-380.2 274.7 65.7 65.8"><style>.st0{fill:#e0e0e0}.st1{fill:#fff}.st2{clip-path:url(#SVGID_2_);fill:#fbbc05}.st3{clip-path:url(#SVGID_4_);fill:#ea4335}.st4{clip-path:url(#SVGID_6_);fill:#34a853}.st5{clip-path:url(#SVGID_8_);fill:#4285f4}</style><circle class="st0" cx="-347.3" cy="307.6" r="32.9"/><circle class="st1" cx="-347.3" cy="307.1" r="32.4"/><g><defs><path id="SVGID_1_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" overflow="visible"/></clipPath><path class="st2" d="M-370.8 320.3v-26l17 13z"/><defs><path id="SVGID_3_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_4_"><use xlink:href="#SVGID_3_" overflow="visible"/></clipPath><path class="st3" d="M-370.8 294.3l17 13 7-6.1 24-3.9v-14h-48z"/><g><defs><path id="SVGID_5_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_6_"><use xlink:href="#SVGID_5_" overflow="visible"/></clipPath><path class="st4" d="M-370.8 320.3l30-23 7.9 1 10.1-15v48h-48z"/></g><g><defs><path id="SVGID_7_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_8_"><use xlink:href="#SVGID_7_" overflow="visible"/></clipPath><path class="st5" d="M-322.8 331.3l-31-24-4-3 35-10z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="2500" stroke-linejoin="round" stroke-miterlimit="2" viewBox="7.002 8.287 148.203 175.426" width="2112" xmlns="http://www.w3.org/2000/svg"><path d="m7.002 8.287 39.319 21.172v39.32h57.467l36.295 21.172h-133.081zm148.203 75.615v-54.443l-42.344-21.172v51.418zm0 99.811-39.319-21.172v-39.32h-57.467l-36.295-21.172h133.081zm-148.203-75.615v54.443l42.343 21.172v-51.418z" fill="#6747c7"/></svg>

After

Width:  |  Height:  |  Size: 441 B

1
public/logos/sanity.svg Normal file
View file

@ -0,0 +1 @@
<svg height="502" viewBox="1.2 .4 352.2 70.9" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m264 25.5v44.7h-14.4v-51.7z" fill="#f37368"/><path d="m60.2 61.9 10.8 9.4 45.5-23.7-4.7-11.3zm189.4-28.8 36.6-19-6.2-10.7-30.4 15.1z" fill="#f7a199"/><path d="m146.6 22v48.2h-13.8v-68.7zm-86.4 39.9 10.8 9.4 20.8-52-6.7-17.8z" fill="#f37368"/><path d="m85.1 1.5h14.2l26.3 68.7h-14.6zm63.6 0 30.3 46.9v21.8l-46.2-68.7zm56.3 0h14.3v68.7h-14.3zm44.6 12.6h-22v-12.6h51.3l7.3 12.6h-22.2z" fill="#f04939"/><path d="m328.5 44.1v26.1h-14.2v-26.1" fill="#f7a199"/><path d="m338.3 1.5-24 42.6h14.2l24.9-42.6z" fill="#f04939"/><path d="m314.3 44.1-24.8-42.6h15.6l16.7 29.2z" fill="#f37368"/><path d="m6.9 9.5c0 9.5 5.9 15.2 17.7 18.2l12.5 2.9c11.2 2.6 18 9 18 19.4.1 4.5-1.4 8.9-4.1 12.5 0-10.4-5.4-16-18.3-19.4l-12.3-2.8c-9.9-2.2-17.5-7.5-17.5-18.8 0-4.3 1.4-8.6 4-12" fill="#f04939"/><g fill="#f37368"><path d="m179 44.9v-43.4h13.8v68.7h-13.8z"/><path d="m43.3 47.4c5.3 3.4 7.7 8.2 7.7 15.1-4.5 5.7-12.2 8.8-21.3 8.8-15.3 0-26.2-7.6-28.5-20.7h14.7c1.9 6 6.9 8.8 13.7 8.8 8.1.1 13.6-4.3 13.7-12m-28.7-23.8c-5-3-7.9-8.4-7.7-14.2 4.3-5.6 11.7-9 20.7-9 15.7 0 24.7 8.3 27 19.9h-14.2c-1.6-4.6-5.5-8.2-12.6-8.2-7.7.1-12.9 4.5-13.2 11.5"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/logos/shopify.svg Normal file
View file

@ -0,0 +1 @@
<svg width="2192" height="2500" viewBox="0 0 256 292" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M223.774 57.34c-.201-1.46-1.48-2.268-2.537-2.357-1.055-.088-23.383-1.743-23.383-1.743s-15.507-15.395-17.209-17.099c-1.703-1.703-5.029-1.185-6.32-.805-.19.056-3.388 1.043-8.678 2.68-5.18-14.906-14.322-28.604-30.405-28.604-.444 0-.901.018-1.358.044C129.31 3.407 123.644.779 118.75.779c-37.465 0-55.364 46.835-60.976 70.635-14.558 4.511-24.9 7.718-26.221 8.133-8.126 2.549-8.383 2.805-9.45 10.462C21.3 95.806.038 260.235.038 260.235l165.678 31.042 89.77-19.42S223.973 58.8 223.775 57.34zM156.49 40.848l-14.019 4.339c.005-.988.01-1.96.01-3.023 0-9.264-1.286-16.723-3.349-22.636 8.287 1.04 13.806 10.469 17.358 21.32zm-27.638-19.483c2.304 5.773 3.802 14.058 3.802 25.238 0 .572-.005 1.095-.01 1.624-9.117 2.824-19.024 5.89-28.953 8.966 5.575-21.516 16.025-31.908 25.161-35.828zm-11.131-10.537c1.617 0 3.246.549 4.805 1.622-12.007 5.65-24.877 19.88-30.312 48.297l-22.886 7.088C75.694 46.16 90.81 10.828 117.72 10.828z" fill="#95BF46"/><path d="M221.237 54.983c-1.055-.088-23.383-1.743-23.383-1.743s-15.507-15.395-17.209-17.099c-.637-.634-1.496-.959-2.394-1.099l-12.527 256.233 89.762-19.418S223.972 58.8 223.774 57.34c-.201-1.46-1.48-2.268-2.537-2.357" fill="#5E8E3E"/><path d="M135.242 104.585l-11.069 32.926s-9.698-5.176-21.586-5.176c-17.428 0-18.305 10.937-18.305 13.693 0 15.038 39.2 20.8 39.2 56.024 0 27.713-17.577 45.558-41.277 45.558-28.44 0-42.984-17.7-42.984-17.7l7.615-25.16s14.95 12.835 27.565 12.835c8.243 0 11.596-6.49 11.596-11.232 0-19.616-32.16-20.491-32.16-52.724 0-27.129 19.472-53.382 58.778-53.382 15.145 0 22.627 4.338 22.627 4.338" fill="#FFF"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

1
public/logos/stripe.svg Normal file
View file

@ -0,0 +1 @@
<svg width="2500" height="1045" viewBox="0 0 512 214" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M35.982 83.484c0-5.546 4.551-7.68 12.09-7.68 10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92 0 6.541-5.688 8.675-13.653 8.675-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106 29.582 0 49.92-14.648 49.92-40.106-.142-42.382-54.329-34.845-54.329-50.774zm96.142-66.986l-34.702 7.395-.142 113.92c0 21.05 15.787 36.551 36.836 36.551 11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022l.142-30.862zm71.112 41.386L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524zm38.257-10.524h35.698v124.444h-35.698V47.36zm0-10.809l35.698-7.68V0l-35.698 7.538V36.55zm109.938 8.391c-13.938 0-22.898 6.542-27.875 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96 25.458 0 48.64-20.48 48.64-65.564-.142-41.245-23.609-63.716-48.498-63.716zm-8.533 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968 12.942 0 21.902 14.506 21.902 33.137 0 19.058-8.818 33.28-21.902 33.28zM512 110.08c0-36.409-17.636-65.138-51.342-65.138-33.85 0-54.33 28.73-54.33 64.854 0 42.808 24.179 64.426 58.88 64.426 16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658zm-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756 8.675 0 17.92 6.685 17.92 22.756h-36.694z" fill="#6772E5"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg xml:space="preserve" viewBox="13.968 21.023999999999997 26.064 30.096" y="0" x="0" xmlns="http://www.w3.org/2000/svg" version="1.1" style="max-height: 500px" width="26.064" height="30.096"><path fill="#1F1F1F" d="M45.209 38.042c.116 1.081 1.099 2.934 3.952 2.934 2.487 0 3.682-1.583 3.682-3.127 0-1.39-.945-2.528-2.815-2.914l-1.35-.289c-.52-.097-.867-.386-.867-.849 0-.54.54-.946 1.215-.946 1.079 0 1.484.714 1.561 1.274l2.14-.483c-.116-1.023-1.022-2.741-3.72-2.741-2.043 0-3.547 1.409-3.547 3.107 0 1.332.829 2.432 2.66 2.837l1.253.29c.732.154 1.022.502 1.022.926 0 .502-.405.946-1.253.946-1.118 0-1.677-.695-1.735-1.448zm15.828 2.644h2.448a16 16 0 0 1-.096-1.718V31.19h-2.564v5.52c0 1.1-.655 1.872-1.793 1.872-1.195 0-1.735-.849-1.735-1.911V31.19h-2.564v6.002c0 2.065 1.311 3.744 3.605 3.744 1.002 0 2.102-.386 2.622-1.274 0 .387.038.831.077 1.024m7.651 3.667V39.76c.463.637 1.427 1.158 2.738 1.158 2.679 0 4.472-2.123 4.472-4.999 0-2.818-1.6-4.941-4.376-4.941-1.426 0-2.487.637-2.911 1.37V31.19h-2.487v13.163zm4.685-8.415c0 1.698-1.041 2.683-2.352 2.683s-2.371-1.004-2.371-2.683 1.06-2.663 2.371-2.663 2.352.984 2.352 2.663M77.35 38.1c0 1.486 1.234 2.856 3.258 2.856 1.407 0 2.313-.656 2.795-1.409 0 .367.039.888.096 1.139h2.352a11 11 0 0 1-.115-1.505V34.51c0-1.911-1.118-3.609-4.125-3.609-2.545 0-3.914 1.641-4.068 3.127l2.275.482c.077-.83.694-1.544 1.812-1.544 1.08 0 1.6.56 1.6 1.235 0 .328-.173.598-.713.676l-2.333.347c-1.581.232-2.834 1.178-2.834 2.876m3.798.946c-.829 0-1.234-.54-1.234-1.1 0-.733.52-1.1 1.176-1.197l2.14-.328v.425c0 1.679-1.003 2.2-2.082 2.2m9.659 1.64v-1.158c.501.811 1.503 1.39 2.814 1.39 2.699 0 4.473-2.142 4.473-5.018 0-2.818-1.6-4.96-4.376-4.96-1.407 0-2.448.618-2.872 1.293v-5.52H88.32v13.974zm4.723-4.767c0 1.737-1.041 2.702-2.352 2.702-1.291 0-2.371-.984-2.371-2.702 0-1.737 1.079-2.683 2.371-2.683 1.311 0 2.352.946 2.352 2.683m4.016 2.181c0 1.486 1.234 2.856 3.258 2.856 1.407 0 2.313-.656 2.795-1.409 0 .367.039.888.096 1.139h2.352a11 11 0 0 1-.115-1.505V34.51c0-1.911-1.118-3.609-4.126-3.609-2.545 0-3.913 1.641-4.067 3.127l2.274.482c.077-.83.694-1.544 1.812-1.544 1.08 0 1.6.56 1.6 1.235 0 .328-.173.598-.713.676l-2.332.347c-1.581.232-2.834 1.178-2.834 2.876m3.797.946c-.829 0-1.234-.54-1.234-1.1 0-.733.52-1.1 1.176-1.197l2.14-.328v.425c0 1.679-1.002 2.2-2.082 2.2m6.363-1.004c.115 1.081 1.099 2.934 3.952 2.934 2.487 0 3.682-1.583 3.682-3.127 0-1.39-.944-2.528-2.814-2.914l-1.35-.289c-.52-.097-.867-.386-.867-.849 0-.54.54-.946 1.214-.946 1.08 0 1.484.714 1.562 1.274l2.14-.483c-.116-1.023-1.022-2.741-3.72-2.741-2.044 0-3.547 1.409-3.547 3.107 0 1.332.829 2.432 2.66 2.837l1.253.29c.733.154 1.022.502 1.022.926 0 .502-.405.946-1.253.946-1.118 0-1.677-.695-1.735-1.448zm11.528-3.184c.058-.868.791-1.872 2.121-1.872 1.465 0 2.082.926 2.121 1.872zm4.492 2.47c-.309.849-.964 1.448-2.159 1.448-1.272 0-2.333-.907-2.39-2.162h6.786c0-.039.038-.425.038-.791 0-3.049-1.754-4.922-4.684-4.922-2.429 0-4.665 1.969-4.665 4.999 0 3.204 2.294 5.076 4.897 5.076 2.333 0 3.836-1.37 4.318-3.011z"/><linearGradient gradientTransform="matrix(1 0 0 -1 0 2954)" y2="2913.308" x2="33.464" y1="2916.561" x1="25.709" gradientUnits="userSpaceOnUse" id="a"><stop stop-color="#249361" offset="0"/><stop stop-color="#3ECF8E" offset="1"/></linearGradient><path fill="url(#a)" d="M28.288 46.44c-.552.695-1.671.314-1.684-.573L26.41 32.89h8.726c1.58 0 2.462 1.825 1.479 3.063z"/><linearGradient gradientTransform="matrix(1 0 0 -1 0 2954)" y2="2917.933" x2="29.617" y1="2924.591" x1="26.081" gradientUnits="userSpaceOnUse" id="b"><stop stop-color="#000000" offset="0"/><stop stop-opacity="0" stop-color="#000000" offset="1"/></linearGradient><path fill-opacity=".2" fill="url(#b)" d="M28.288 46.44c-.552.695-1.671.314-1.684-.573L26.41 32.89h8.726c1.58 0 2.462 1.825 1.479 3.063z"/><path fill="#3ECF8E" d="M24.74 25.56c.552-.695 1.671-.314 1.684.573l.085 12.977h-8.616c-1.581 0-2.462-1.825-1.479-3.063z"/></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

1
public/logos/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 74.63808000000002 68.24778761061947" fill="none" style="max-height: 500px" width="74.63808000000002" height="68.24778761061947"><path fill="#000" d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5zM36.95 0L73.9 64H0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="16 16 992 992"><path fill="#21759b" d="M123.3 338.8l203 556.1C184.4 826 86.5 680.4 86.5 512a424.1 424.1 0 0 1 36.8-173.2zm675.9 151.7c0-52.6-18.9-89-35.1-117.3-21.5-35.1-41.7-64.8-41.7-99.8 0-39.1 29.6-75.5 71.4-75.5 1.9 0 3.7.2 5.5.3A423.6 423.6 0 0 0 512 86.5c-148.7 0-279.4 76.3-355.5 191.8 10 .3 19.4.5 27.4.5 44.5 0 113.4-5.4 113.4-5.4 22.9-1.4 25.6 32.3 2.7 35.1 0 0-23.1 2.7-48.7 4l154.9 460.9 93.1-279.3-66.2-181.6c-23-1.4-44.7-4.1-44.7-4.1-22.9-1.3-20.2-36.4 2.7-35 0 0 70.3 5.4 112.1 5.4 44.5 0 113.4-5.4 113.4-5.4 23-1.4 25.6 32.3 2.7 35 0 0-23.1 2.7-48.7 4.1l153.8 457.4 42.4-141.8c18.4-58.9 32.4-101.2 32.4-137.6zm-279.7 58.7l-127.7 371a426.3 426.3 0 0 0 261.5-6.8 29.7 29.7 0 0 1-3-5.9zm365.8-241.4a315.7 315.7 0 0 1 2.9 43.8c0 43.2-8.1 91.7-32.4 152.4L725.9 879.8C852.4 806 937.5 669 937.5 512a423 423 0 0 0-52.2-204.2zM1008 512c0 273.5-222.5 496-496 496S16 785.5 16 512 238.5 16 512 16s496 222.5 496 496zm-22.8 0c0-261-212.3-473.3-473.2-473.3S38.7 251 38.7 512 251 985.3 512 985.3 985.2 773 985.2 512z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB