mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-29 23:25:58 +02:00
complete integration of direct share link as discussed in #35
This commit is contained in:
parent
bfd1b21f9c
commit
a2ed7c7117
15 changed files with 215 additions and 37 deletions
|
@ -12,6 +12,7 @@ server:
|
|||
secure_cookies: false
|
||||
session_cookie_name: p_session
|
||||
resource_session_cookie_name: p_resource_session
|
||||
resource_access_token_param: p_token
|
||||
|
||||
traefik:
|
||||
cert_resolver: letsencrypt
|
||||
|
|
|
@ -9,9 +9,10 @@ server:
|
|||
internal_port: 3001
|
||||
next_port: 3002
|
||||
internal_hostname: pangolin
|
||||
secure_cookies: false
|
||||
secure_cookies: true
|
||||
session_cookie_name: p_session
|
||||
resource_session_cookie_name: p_resource_session
|
||||
resource_access_token_param: p_token
|
||||
|
||||
traefik:
|
||||
cert_resolver: letsencrypt
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@fosrl/pangolin",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0-beta.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||
|
@ -26,6 +26,7 @@
|
|||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
|
|
|
@ -32,7 +32,8 @@ const environmentSchema = z.object({
|
|||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||
secure_cookies: z.boolean(),
|
||||
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({
|
||||
http_entrypoint: z.string(),
|
||||
|
@ -186,6 +187,7 @@ export class Config {
|
|||
?.disable_user_create_org
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param;
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ export async function traefikConfigProvider(
|
|||
config.getRawConfig().server.resource_session_cookie_name,
|
||||
userSessionCookieName:
|
||||
config.getRawConfig().server.session_cookie_name,
|
||||
accessTokenQueryParam: "p_token"
|
||||
accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -9,6 +9,7 @@ import { loadAppVersion } from "@server/lib/loadAppVersion";
|
|||
import m1 from "./scripts/1.0.0-beta1";
|
||||
import m2 from "./scripts/1.0.0-beta2";
|
||||
import m3 from "./scripts/1.0.0-beta3";
|
||||
import m4 from "./scripts/1.0.0-beta5";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -17,7 +18,8 @@ import m3 from "./scripts/1.0.0-beta3";
|
|||
const migrations = [
|
||||
{ version: "1.0.0-beta.1", run: m1 },
|
||||
{ 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
|
||||
] as const;
|
||||
|
||||
|
|
42
server/setup/scripts/1.0.0-beta5.ts
Normal file
42
server/setup/scripts/1.0.0-beta5.ts
Normal 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.");
|
||||
}
|
|
@ -57,14 +57,22 @@ import {
|
|||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { register } from "module";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import {
|
||||
constructDirectShareLink,
|
||||
constructShareLink
|
||||
} from "@app/lib/shareLinks";
|
||||
import { ShareLinkRow } from "./ShareLinksTable";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
|
@ -75,6 +83,7 @@ type FormProps = {
|
|||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: "Please select a resource" }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
|
@ -88,14 +97,18 @@ export default function CreateShareLinkForm({
|
|||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [directLink, setDirectLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [resources, setResources] = useState<
|
||||
{ resourceId: number; name: string }[]
|
||||
{ resourceId: number; name: string; resourceUrl: string }[]
|
||||
>([]);
|
||||
|
||||
const timeUnits = [
|
||||
|
@ -139,7 +152,13 @@ export default function CreateShareLinkForm({
|
|||
});
|
||||
|
||||
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
|
||||
);
|
||||
setLink(link);
|
||||
const directLink = constructDirectShareLink(
|
||||
env.server.resourceAccessTokenParam,
|
||||
values.resourceUrl,
|
||||
token.accessTokenId,
|
||||
token.accessToken
|
||||
);
|
||||
setDirectLink(directLink);
|
||||
onCreated?.({
|
||||
accessTokenId: token.accessTokenId,
|
||||
resourceId: token.resourceId,
|
||||
|
@ -306,6 +332,10 @@ export default function CreateShareLinkForm({
|
|||
"resourceName",
|
||||
r.name
|
||||
);
|
||||
form.setValue(
|
||||
"resourceUrl",
|
||||
r.resourceUrl
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
|
@ -462,12 +492,62 @@ export default function CreateShareLinkForm({
|
|||
<QRCodeCanvas value={link} size={200} />
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<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>
|
||||
|
|
|
@ -24,7 +24,7 @@ import { useRouter } from "next/navigation";
|
|||
// import CreateResourceForm from "./CreateResourceForm";
|
||||
import { useState } from "react";
|
||||
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 { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
@ -109,15 +109,14 @@ export default function ShareLinksTable({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
onClick={() =>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
deleteSharelink(
|
||||
resourceRow.accessTokenId
|
||||
)
|
||||
}
|
||||
className="text-red-500"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button className="text-red-500">
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
@ -30,6 +30,7 @@ export default function AccessToken({
|
|||
redirectUrl
|
||||
}: AccessTokenProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
|
@ -49,6 +50,7 @@ export default function AccessToken({
|
|||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -61,24 +63,47 @@ export default function AccessToken({
|
|||
check();
|
||||
}, [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 ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
Access URL Invalid
|
||||
{renderTitle()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<CardContent>{renderContent()}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ const buttonVariants = cva(
|
|||
secondary:
|
||||
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
text: "",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
|
|
11
src/components/ui/collapsible.tsx
Normal file
11
src/components/ui/collapsible.tsx
Normal 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 }
|
|
@ -6,7 +6,8 @@ export function pullEnv(): Env {
|
|||
nextPort: process.env.NEXT_PORT as string,
|
||||
externalPort: process.env.SERVER_EXTERNAL_PORT 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: {
|
||||
environment: process.env.ENVIRONMENT as string,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { pullEnv } from "./pullEnv";
|
||||
|
||||
export function constructShareLink(
|
||||
resourceId: number,
|
||||
id: string,
|
||||
|
@ -5,3 +7,12 @@ export function constructShareLink(
|
|||
) {
|
||||
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}`;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export type Env = {
|
|||
nextPort: string;
|
||||
sessionCookieName: string;
|
||||
resourceSessionCookieName: string;
|
||||
resourceAccessTokenParam: string;
|
||||
},
|
||||
email: {
|
||||
emailEnabled: boolean;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue