complete integration of direct share link as discussed in #35

This commit is contained in:
Milo Schwartz 2025-01-12 13:43:16 -05:00
parent bfd1b21f9c
commit a2ed7c7117
No known key found for this signature in database
15 changed files with 215 additions and 37 deletions

View file

@ -12,6 +12,7 @@ server:
secure_cookies: false secure_cookies: false
session_cookie_name: p_session session_cookie_name: p_session
resource_session_cookie_name: p_resource_session resource_session_cookie_name: p_resource_session
resource_access_token_param: p_token
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: letsencrypt

View file

@ -9,9 +9,10 @@ server:
internal_port: 3001 internal_port: 3001
next_port: 3002 next_port: 3002
internal_hostname: pangolin internal_hostname: pangolin
secure_cookies: false secure_cookies: true
session_cookie_name: p_session session_cookie_name: p_session
resource_session_cookie_name: p_resource_session resource_session_cookie_name: p_resource_session
resource_access_token_param: p_token
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: letsencrypt

View file

@ -1,6 +1,6 @@
{ {
"name": "@fosrl/pangolin", "name": "@fosrl/pangolin",
"version": "1.0.0-beta.4", "version": "1.0.0-beta.5",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
@ -26,6 +26,7 @@
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.2", "@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3", "@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4", "@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-icons": "1.3.2", "@radix-ui/react-icons": "1.3.2",

View file

@ -32,7 +32,8 @@ const environmentSchema = z.object({
internal_hostname: z.string().transform((url) => url.toLowerCase()), internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(), secure_cookies: z.boolean(),
session_cookie_name: z.string(), session_cookie_name: z.string(),
resource_session_cookie_name: z.string() resource_session_cookie_name: z.string(),
resource_access_token_param: z.string()
}), }),
traefik: z.object({ traefik: z.object({
http_entrypoint: z.string(), http_entrypoint: z.string(),
@ -186,6 +187,7 @@ export class Config {
?.disable_user_create_org ?.disable_user_create_org
? "true" ? "true"
: "false"; : "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param;
this.rawConfig = parsedConfig.data; this.rawConfig = parsedConfig.data;
} }

View file

@ -56,7 +56,7 @@ export async function traefikConfigProvider(
config.getRawConfig().server.resource_session_cookie_name, config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName: userSessionCookieName:
config.getRawConfig().server.session_cookie_name, config.getRawConfig().server.session_cookie_name,
accessTokenQueryParam: "p_token" accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
}, },
}, },
}, },

View file

@ -9,6 +9,7 @@ import { loadAppVersion } from "@server/lib/loadAppVersion";
import m1 from "./scripts/1.0.0-beta1"; import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2"; import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3"; import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -17,7 +18,8 @@ import m3 from "./scripts/1.0.0-beta3";
const migrations = [ const migrations = [
{ version: "1.0.0-beta.1", run: m1 }, { version: "1.0.0-beta.1", run: m1 },
{ version: "1.0.0-beta.2", run: m2 }, { version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 } { version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -0,0 +1,42 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.5...");
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
// Validate the structure
if (!rawConfig.server) {
throw new Error(`Invalid config file: server is missing.`);
}
// Update the config
rawConfig.server.resource_access_token_param = "p_token";
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log("Done.");
}

View file

@ -57,14 +57,22 @@ import {
CommandItem, CommandItem,
CommandList CommandList
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CheckIcon } from "lucide-react"; import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { register } from "module"; import { register } from "module";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks"; import {
constructDirectShareLink,
constructShareLink
} from "@app/lib/shareLinks";
import { ShareLinkRow } from "./ShareLinksTable"; import { ShareLinkRow } from "./ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
type FormProps = { type FormProps = {
open: boolean; open: boolean;
@ -75,6 +83,7 @@ type FormProps = {
const formSchema = z.object({ const formSchema = z.object({
resourceId: z.number({ message: "Please select a resource" }), resourceId: z.number({ message: "Please select a resource" }),
resourceName: z.string(), resourceName: z.string(),
resourceUrl: z.string(),
timeUnit: z.string(), timeUnit: z.string(),
timeValue: z.coerce.number().int().positive().min(1), timeValue: z.coerce.number().int().positive().min(1),
title: z.string().optional() title: z.string().optional()
@ -88,14 +97,18 @@ export default function CreateShareLinkForm({
const { toast } = useToast(); const { toast } = useToast();
const { org } = useOrgContext(); const { org } = useOrgContext();
const api = createApiClient(useEnvContext()); const { env } = useEnvContext();
const api = createApiClient({ env });
const [link, setLink] = useState<string | null>(null); const [link, setLink] = useState<string | null>(null);
const [directLink, setDirectLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [neverExpire, setNeverExpire] = useState(false); const [neverExpire, setNeverExpire] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [resources, setResources] = useState< const [resources, setResources] = useState<
{ resourceId: number; name: string }[] { resourceId: number; name: string; resourceUrl: string }[]
>([]); >([]);
const timeUnits = [ const timeUnits = [
@ -139,7 +152,13 @@ export default function CreateShareLinkForm({
}); });
if (res?.status === 200) { if (res?.status === 200) {
setResources(res.data.data.resources); setResources(
res.data.data.resources.map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
}))
);
} }
} }
@ -202,6 +221,13 @@ export default function CreateShareLinkForm({
token.accessToken token.accessToken
); );
setLink(link); setLink(link);
const directLink = constructDirectShareLink(
env.server.resourceAccessTokenParam,
values.resourceUrl,
token.accessTokenId,
token.accessToken
);
setDirectLink(directLink);
onCreated?.({ onCreated?.({
accessTokenId: token.accessTokenId, accessTokenId: token.accessTokenId,
resourceId: token.resourceId, resourceId: token.resourceId,
@ -306,6 +332,10 @@ export default function CreateShareLinkForm({
"resourceName", "resourceName",
r.name r.name
); );
form.setValue(
"resourceUrl",
r.resourceUrl
);
}} }}
> >
<CheckIcon <CheckIcon
@ -462,12 +492,62 @@ export default function CreateShareLinkForm({
<QRCodeCanvas value={link} size={200} /> <QRCodeCanvas value={link} size={200} />
</div> </div>
<div className="mx-auto"> <Collapsible
<CopyTextBox open={isOpen}
text={link} onOpenChange={setIsOpen}
wrapText={false} className="space-y-2"
/> >
</div> <div className="mx-auto">
<CopyTextBox
text={link}
wrapText={false}
/>
</div>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
See alternative share
links
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2">
{directLink && (
<div className="space-y-1">
<div className="mx-auto">
<CopyTextBox
text={directLink}
wrapText={false}
/>
</div>
<p className="text-sm text-muted-foreground">
This link does not
require visiting in a
browser to complete the
redirect. It contains
the access token
directly in the URL,
which can be useful for
sharing with clients
that do not support
redirects.
</p>
</div>
)}
</CollapsibleContent>
</Collapsible>
</div> </div>
)} )}
</div> </div>

View file

@ -24,7 +24,7 @@ import { useRouter } from "next/navigation";
// import CreateResourceForm from "./CreateResourceForm"; // import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react"; import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
@ -109,15 +109,14 @@ export default function ShareLinksTable({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem> <DropdownMenuItem
<button onClick={() => {
onClick={() => deleteSharelink(
deleteSharelink( resourceRow.accessTokenId
resourceRow.accessTokenId );
) }}
} >
className="text-red-500" <button className="text-red-500">
>
Delete Delete
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -30,6 +30,7 @@ export default function AccessToken({
redirectUrl redirectUrl
}: AccessTokenProps) { }: AccessTokenProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false);
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@ -49,6 +50,7 @@ export default function AccessToken({
}); });
if (res.data.data.session) { if (res.data.data.session) {
setIsValid(true);
window.location.href = redirectUrl; window.location.href = redirectUrl;
} }
} catch (e) { } catch (e) {
@ -61,24 +63,47 @@ export default function AccessToken({
check(); check();
}, [accessTokenId, accessToken]); }, [accessTokenId, accessToken]);
function renderTitle() {
if (isValid) {
return "Access Granted";
} else {
return "Access URL Invalid";
}
}
function renderContent() {
if (isValid) {
return (
<div>
You have been granted access to this resource. Redirecting
you...
</div>
);
} else {
return (
<div>
This shared access URL is invalid. Please contact the
resource owner for a new URL.
<div className="text-center mt-4">
<Button>
<Link href="/">Go Home</Link>
</Button>
</div>
</div>
);
}
}
return loading ? ( return loading ? (
<div></div> <div></div>
) : ( ) : (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-center text-2xl font-bold"> <CardTitle className="text-center text-2xl font-bold">
Access URL Invalid {renderTitle()}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>{renderContent()}</CardContent>
This shared access URL is invalid. Please contact the resource
owner for a new URL.
<div className="text-center mt-4">
<Button>
<Link href="/">Go Home</Link>
</Button>
</div>
</CardContent>
</Card> </Card>
); );
} }

View file

@ -19,6 +19,7 @@ const buttonVariants = cva(
secondary: secondary:
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80", "bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
text: "",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {

View file

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -6,7 +6,8 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string, nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string,
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string
}, },
app: { app: {
environment: process.env.ENVIRONMENT as string, environment: process.env.ENVIRONMENT as string,

View file

@ -1,3 +1,5 @@
import { pullEnv } from "./pullEnv";
export function constructShareLink( export function constructShareLink(
resourceId: number, resourceId: number,
id: string, id: string,
@ -5,3 +7,12 @@ export function constructShareLink(
) { ) {
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`; return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
} }
export function constructDirectShareLink(
param: string,
resourceUrl: string,
id: string,
token: string
) {
return `${resourceUrl}?${param}=${id}.${token}`;
}

View file

@ -8,6 +8,7 @@ export type Env = {
nextPort: string; nextPort: string;
sessionCookieName: string; sessionCookieName: string;
resourceSessionCookieName: string; resourceSessionCookieName: string;
resourceAccessTokenParam: string;
}, },
email: { email: {
emailEnabled: boolean; emailEnabled: boolean;