Merge pull request #2785 from homarr-labs/copilot/add-github-oauth-login

This commit is contained in:
ajnart
2026-01-08 12:26:41 +01:00
committed by GitHub
10 changed files with 196 additions and 149 deletions

View File

@@ -11,6 +11,9 @@ A web application to browse, search, and download icons from the [Dashboard Icon
- Copy icon URLs directly to your clipboard
- Responsive design that works on mobile, tablet, and desktop
- Dark mode support
- **User authentication** - Sign in with email/password or GitHub OAuth
- **Submit icons** - Authenticated users can submit new icons to the collection
- **Admin dashboard** - Admins can approve, reject, and manage icon submissions
## Tech Stack
@@ -18,6 +21,8 @@ A web application to browse, search, and download icons from the [Dashboard Icon
- **TypeScript v5** - Type-safe JavaScript
- **Tailwind CSS** - Utility-first CSS framework
- **Shadcn UI** - Reusable components built with Radix UI and Tailwind
- **PocketBase** - Backend for authentication and data storage
- **PostHog** - Product analytics and user tracking
## Project Structure
@@ -67,8 +72,31 @@ src/
3. Create a `.env` file with the following variables:
```
GITHUB_TOKEN=your_github_token
NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090
```
4. Start the development server:
4. **Configure GitHub OAuth (Optional):**
To enable GitHub OAuth login, you need to create a GitHub OAuth App and configure it in PocketBase:
a. Create a GitHub OAuth App:
- Go to GitHub Settings → Developer settings → OAuth Apps → New OAuth App
- Set Application name: "Dashboard Icons" (or your preferred name)
- Set Homepage URL: `http://localhost:3000` (for development)
- Set Authorization callback URL: `http://localhost:8090/api/oauth2-redirect`
- After creation, note the **Client ID** and generate a **Client Secret**
b. Configure PocketBase OAuth:
- Start PocketBase: `pnpm run backend:start`
- Open PocketBase admin UI at `http://127.0.0.1:8090/_/`
- Navigate to Settings → Auth providers
- Enable GitHub provider and enter your Client ID and Client Secret
- Save the settings
c. For production deployment:
- Update the Authorization callback URL to: `https://pb.dashboardicons.com/api/oauth2-redirect`
- Configure the same OAuth settings in your production PocketBase instance
5. Start the development server:
```bash
pnpm dev
```

View File

@@ -1,52 +1,48 @@
"use server"
import PocketBase from "pocketbase";
import PocketBase from "pocketbase"
const GITHUB_OWNER = "homarr-labs";
const GITHUB_REPO = "dashboard-icons";
const WORKFLOW_FILE = "add-icon.yml";
const GITHUB_OWNER = "homarr-labs"
const GITHUB_REPO = "dashboard-icons"
const WORKFLOW_FILE = "add-icon.yml"
interface TriggerWorkflowResult {
success: boolean;
error?: string;
workflowUrl?: string;
success: boolean
error?: string
workflowUrl?: string
}
/**
* Verify the provided auth token belongs to an admin user
* The token is passed from the client since auth is stored in localStorage
*/
async function verifyAdmin(
authToken: string,
): Promise<{ isAdmin: boolean; error?: string }> {
async function verifyAdmin(authToken: string): Promise<{ isAdmin: boolean; error?: string }> {
if (!authToken) {
return { isAdmin: false, error: "Not authenticated" };
return { isAdmin: false, error: "Not authenticated" }
}
try {
const pb = new PocketBase(
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090",
);
const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090")
// Validate the token by refreshing auth
// This will fail if the token is invalid/expired
pb.authStore.save(authToken, null);
pb.authStore.save(authToken, null)
const authData = await pb.collection("users").authRefresh();
const authData = await pb.collection("users").authRefresh()
if (!authData?.record) {
return { isAdmin: false, error: "Invalid authentication" };
return { isAdmin: false, error: "Invalid authentication" }
}
// Check if user is admin
if (!authData.record.admin) {
return { isAdmin: false, error: "User is not an admin" };
return { isAdmin: false, error: "User is not an admin" }
}
return { isAdmin: true };
return { isAdmin: true }
} catch (error) {
console.error("Error verifying admin:", error);
return { isAdmin: false, error: "Failed to verify admin status" };
console.error("Error verifying admin:", error)
return { isAdmin: false, error: "Failed to verify admin status" }
}
}
@@ -57,21 +53,17 @@ async function verifyAdmin(
* @param submissionIds - Single ID or comma-separated IDs of submissions to add
* @param dryRun - If true, skip actual writes (for testing)
*/
export async function triggerAddIconWorkflow(
authToken: string,
submissionIds: string,
dryRun = false,
): Promise<TriggerWorkflowResult> {
export async function triggerAddIconWorkflow(authToken: string, submissionIds: string, dryRun = false): Promise<TriggerWorkflowResult> {
// Verify admin status using the provided token
const { isAdmin, error: authError } = await verifyAdmin(authToken);
const { isAdmin, error: authError } = await verifyAdmin(authToken)
if (!isAdmin) {
return { success: false, error: authError || "Unauthorized" };
return { success: false, error: authError || "Unauthorized" }
}
// Check for GitHub token
const githubToken = process.env.GITHUB_TOKEN;
const githubToken = process.env.GITHUB_TOKEN
if (!githubToken) {
return { success: false, error: "GitHub token not configured" };
return { success: false, error: "GitHub token not configured" }
}
try {
@@ -93,40 +85,39 @@ export async function triggerAddIconWorkflow(
},
}),
},
);
)
if (!response.ok) {
const errorText = await response.text();
console.error("GitHub API error:", response.status, errorText);
const errorText = await response.text()
console.error("GitHub API error:", response.status, errorText)
return {
success: false,
error: `GitHub API error: ${response.status} - ${errorText}`,
};
}
}
// The dispatch endpoint returns 204 No Content on success
// Construct a URL to the workflow runs page
const workflowUrl = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${WORKFLOW_FILE}`;
const workflowUrl = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${WORKFLOW_FILE}`
return {
success: true,
workflowUrl,
};
}
} catch (error) {
console.error("Error triggering workflow:", error);
console.error("Error triggering workflow:", error)
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to trigger workflow",
};
error: error instanceof Error ? error.message : "Failed to trigger workflow",
}
}
}
interface BulkTriggerWorkflowResult {
success: boolean;
error?: string;
workflowUrl?: string;
submissionCount: number;
success: boolean
error?: string
workflowUrl?: string
submissionCount: number
}
/**
@@ -146,17 +137,17 @@ export async function triggerBulkAddIconWorkflow(
success: false,
error: "No submission IDs provided",
submissionCount: 0,
};
}
}
// Join all IDs with commas and trigger a single workflow
const commaSeparatedIds = submissionIds.join(",");
const result = await triggerAddIconWorkflow(authToken, commaSeparatedIds, dryRun);
const commaSeparatedIds = submissionIds.join(",")
const result = await triggerAddIconWorkflow(authToken, commaSeparatedIds, dryRun)
return {
success: result.success,
error: result.error,
workflowUrl: result.workflowUrl,
submissionCount: submissionIds.length,
};
}
}

View File

@@ -10,7 +10,14 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { useApproveSubmission, useAuth, useBulkTriggerWorkflow, useRejectSubmission, useSubmissions, useTriggerWorkflow } from "@/hooks/use-submissions"
import {
useApproveSubmission,
useAuth,
useBulkTriggerWorkflow,
useRejectSubmission,
useSubmissions,
useTriggerWorkflow,
} from "@/hooks/use-submissions"
export default function DashboardPage() {
// Fetch auth status
@@ -230,9 +237,7 @@ export default function DashboardPage() {
<DialogContent>
<DialogHeader>
<DialogTitle>Approve Submission</DialogTitle>
<DialogDescription>
Optional: add a note for the submitter. This will appear in the approval email.
</DialogDescription>
<DialogDescription>Optional: add a note for the submitter. This will appear in the approval email.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">

View File

@@ -327,7 +327,6 @@ export function AdvancedIconSubmissionFormTanStack() {
return (
<div className="max-w-4xl mx-auto">
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="bg-background">
<AlertDialogHeader>

View File

@@ -209,41 +209,28 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
<Label className="text-sm font-semibold">Customize Colors</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
aria-label="Learn more about color customization"
>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" aria-label="Learn more about color customization">
<Info className="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="center">
<p className="text-sm text-muted-foreground">
This feature extracts and allows you to customize fill and
stroke colors found in the SVG. Colors can be defined in various
ways: as attributes (fill="white"), inline styles
(style="fill:#2396ed"), or within style tags. Each unique color
gets its own color picker for easy customization.
This feature extracts and allows you to customize fill and stroke colors found in the SVG. Colors can be defined in various
ways: as attributes (fill="white"), inline styles (style="fill:#2396ed"), or within style tags. Each unique color gets its
own color picker for easy customization.
</p>
</PopoverContent>
</Popover>
</div>
<Button
id="close-customizer"
variant="ghost"
size="sm"
onClick={onClose}
className="h-6 w-6 p-0"
>
<Button id="close-customizer" variant="ghost" size="sm" onClick={onClose} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
{originalColors.map((originalColor, index) => {
const currentColor = colorMappings[originalColor] || originalColor;
const hslColor = hexToHsl(currentColor);
const currentColor = colorMappings[originalColor] || originalColor
const hslColor = hexToHsl(currentColor)
return (
<div key={originalColor} className="space-y-2">
@@ -253,19 +240,17 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
style={{ backgroundColor: originalColor }}
title={`Original: ${originalColor}`}
/>
<Label className="text-xs text-muted-foreground">
Color {index + 1}
</Label>
<Label className="text-xs text-muted-foreground">Color {index + 1}</Label>
</div>
<ColorPicker
color={hslColor}
onChange={(newColor) => {
const hex = hslToHex(newColor);
handleColorChange(originalColor, hex);
const hex = hslToHex(newColor)
handleColorChange(originalColor, hex)
}}
/>
</div>
);
)
})}
</div>
@@ -282,19 +267,12 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
dangerouslySetInnerHTML={{ __html: customizedSvg }}
/>
) : (
<div className="text-muted-foreground text-xs">
Preview loading...
</div>
<div className="text-muted-foreground text-xs">Preview loading...</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={handleCopySvg}
variant="outline"
className="w-full"
size="sm"
>
<Button onClick={handleCopySvg} variant="outline" className="w-full" size="sm">
<Copy className="w-4 h-4 mr-2" />
Copy
</Button>
@@ -304,5 +282,5 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
</Button>
</div>
</motion.div>
);
)
}

View File

@@ -6,7 +6,7 @@ import { ArrowRight, Check, FileType, Github, Moon, Palette, PaletteIcon, Sun, T
import Image from "next/image"
import Link from "next/link"
import type React from "react"
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { IconsGrid } from "@/components/icon-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@@ -224,7 +224,7 @@ export function IconDetails({
const [copiedUrlKey, setCopiedUrlKey] = useState<string | null>(null)
const [copiedImageKey, setCopiedImageKey] = useState<string | null>(null)
const [isCustomizerOpen, setIsCustomizerOpen] = useState(false)
const [hasGradients, setHasGradients] = useState<boolean | null>(null);
const [hasGradients, setHasGradients] = useState<boolean | null>(null)
const launchConfetti = useCallback((originX?: number, originY?: number) => {
if (typeof confetti !== "function") return
@@ -543,33 +543,30 @@ export function IconDetails({
useEffect(() => {
if (!svgUrl) {
setHasGradients(null);
return;
setHasGradients(null)
return
}
const checkForGradients = async () => {
try {
const response = await fetch(svgUrl);
const response = await fetch(svgUrl)
if (!response.ok) {
setHasGradients(null);
return;
setHasGradients(null)
return
}
const text = await response.text();
const hasLinearGradient = /<linearGradient[\s\/>]/i.test(text);
const hasRadialGradient = /<radialGradient[\s\/>]/i.test(text);
setHasGradients(hasLinearGradient || hasRadialGradient);
const text = await response.text()
const hasLinearGradient = /<linearGradient[\s/>]/i.test(text)
const hasRadialGradient = /<radialGradient[\s/>]/i.test(text)
setHasGradients(hasLinearGradient || hasRadialGradient)
} catch {
setHasGradients(null);
setHasGradients(null)
}
};
}
checkForGradients();
}, [svgUrl]);
checkForGradients()
}, [svgUrl])
const canCustomize =
svgUrl !== null &&
availableFormats.includes("svg") &&
hasGradients === false;
const canCustomize = svgUrl !== null && availableFormats.includes("svg") && hasGradients === false
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
@@ -579,12 +576,8 @@ export function IconDetails({
<CardHeader className="pb-4">
<div className="flex flex-col items-center bg-background">
<div className="relative w-32 h-32 rounded-xl ring-1 ring-white/5 dark:ring-white/10 bg-primary/15 dark:bg-secondary/10 overflow-hidden flex items-center justify-center p-3">
<Image
src={
isCommunityIcon && mainIconUrl
? mainIconUrl
: `${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`
}
<Image
src={isCommunityIcon && mainIconUrl ? mainIconUrl : `${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
priority
width={96}
height={96}

View File

@@ -106,7 +106,8 @@ export function IconSubmissionContent() {
<div className="flex flex-col gap-6">
<div className="text-center space-y-2">
<p className="text-muted-foreground">
If you would like to help us expand our collection, you can submit your icons using our submission form or by creating an issue on Github
If you would like to help us expand our collection, you can submit your icons using our submission form or by creating an issue on
Github
</p>
<Button variant="link" asChild className="text-primary">
<Link href="/submit">Submit using the submission form &rarr;</Link>

View File

@@ -106,6 +106,48 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
setTimeout(() => emailRef.current?.focus(), 100)
}
const handleGitHubLogin = async () => {
setError("")
setIsLoading(true)
try {
// Authenticate with GitHub OAuth2 using PocketBase's popup-based flow
await pb.collection("users").authWithOAuth2({
provider: "github",
})
// Identify user immediately after successful authentication
// This follows PostHog best practice of calling identify as soon as possible
identifyUserInPostHog(posthog)
// Track OAuth login event
posthog?.capture("user_oauth_login", {
provider: "github",
})
// Success
onOpenChange(false)
resetForm()
} catch (err: any) {
console.error("GitHub OAuth error:", err)
// Provide specific error messages based on the error type
let errorMessage = "GitHub authentication failed. Please try again."
if (err?.message?.includes("popup") || err?.message?.includes("blocked")) {
errorMessage = "Popup was blocked. Please allow popups for this site and try again."
} else if (err?.message?.includes("cancelled") || err?.message?.includes("closed")) {
errorMessage = "Authentication was cancelled. Please try again if you want to sign in with GitHub."
} else if (err?.message) {
errorMessage = err.message
}
setError(errorMessage)
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-lg bg-background border shadow-2xl">
@@ -132,11 +174,25 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
</div>
)}
{/* GitHub Button (Coming Soon) */}
<Button type="button" variant="outline" className="w-full h-12 text-base font-medium cursor-not-allowed opacity-50" disabled>
<Github className="h-5 w-5 mr-2" />
Continue with GitHub
<span className="ml-2 text-xs text-muted-foreground">(Coming soon)</span>
{/* GitHub Button */}
<Button
type="button"
variant="outline"
className="w-full h-12 text-base font-medium"
onClick={handleGitHubLogin}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Connecting to GitHub...
</>
) : (
<>
<Github className="h-5 w-5 mr-2" />
Continue with GitHub
</>
)}
</Button>
{/* Divider */}
@@ -164,6 +220,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-12 text-base"
disabled={isLoading}
required
/>
{isRegister && (
@@ -185,6 +242,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-12 text-base"
disabled={isLoading}
required
/>
<p className="text-xs text-muted-foreground">This will be displayed publicly with your submissions</p>
@@ -204,6 +262,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-12 text-base"
disabled={isLoading}
required
/>
</div>
@@ -222,6 +281,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-12 text-base"
disabled={isLoading}
required
/>
</div>

View File

@@ -4,12 +4,12 @@ import {
type ColumnDef,
type ColumnFiltersState,
type ExpandedState,
type RowSelectionState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getSortedRowModel,
type RowSelectionState,
type SortingState,
useReactTable,
} from "@tanstack/react-table"
@@ -438,12 +438,7 @@ export function SubmissionsDataTable({
<Button variant="ghost" size="sm" onClick={() => setRowSelection({})}>
Clear selection
</Button>
<Button
size="sm"
onClick={handleBulkTrigger}
disabled={isBulkTriggeringWorkflow}
className="bg-blue-600 hover:bg-blue-700"
>
<Button size="sm" onClick={handleBulkTrigger} disabled={isBulkTriggeringWorkflow} className="bg-blue-600 hover:bg-blue-700">
<Github className="w-4 h-4 mr-2" />
{isBulkTriggeringWorkflow ? "Triggering..." : `Trigger All (${selectedSubmissionIds.length})`}
</Button>

View File

@@ -549,48 +549,45 @@ export function applyColorMappingsToSvg(svg: string, mappings: ColorMapping): st
* Forces width/height to 100% for proper scaling within containers
* Returns the processed SVG string
*/
export function ensureSvgAttributes(
svg: string,
viewBox: string = DEFAULT_VIEWBOX,
): string {
export function ensureSvgAttributes(svg: string, viewBox: string = DEFAULT_VIEWBOX): string {
if (!svg || typeof svg !== "string") {
return svg;
return svg
}
try {
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svg, "image/svg+xml");
const svgElement = svgDoc.documentElement;
const parser = new DOMParser()
const svgDoc = parser.parseFromString(svg, "image/svg+xml")
const svgElement = svgDoc.documentElement
if (!svgElement) {
return svg;
return svg
}
// If no viewBox exists, try to create one from width/height attributes
if (!svgElement.getAttribute("viewBox")) {
const width = svgElement.getAttribute("width");
const height = svgElement.getAttribute("height");
const width = svgElement.getAttribute("width")
const height = svgElement.getAttribute("height")
if (width && height) {
const numWidth = parseFloat(width);
const numHeight = parseFloat(height);
const numWidth = parseFloat(width)
const numHeight = parseFloat(height)
if (!isNaN(numWidth) && !isNaN(numHeight)) {
svgElement.setAttribute("viewBox", `0 0 ${numWidth} ${numHeight}`);
svgElement.setAttribute("viewBox", `0 0 ${numWidth} ${numHeight}`)
}
} else {
svgElement.setAttribute("viewBox", viewBox);
svgElement.setAttribute("viewBox", viewBox)
}
}
// Always set width and height to 100% for proper scaling
svgElement.setAttribute("width", "100%");
svgElement.setAttribute("height", "100%");
svgElement.setAttribute("width", "100%")
svgElement.setAttribute("height", "100%")
const serializer = new XMLSerializer();
return serializer.serializeToString(svgElement);
const serializer = new XMLSerializer()
return serializer.serializeToString(svgElement)
} catch (error) {
console.error("Error ensuring SVG attributes:", error);
return svg;
console.error("Error ensuring SVG attributes:", error)
return svg
}
}