Skip to content
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Omar Goher <omargoher59@gmail.com>
Rawan Essam <rawanessamwork@gmail.com>
Huda Amr <hudaamr2006@gmail.com>
AbdElrhman Mohamed <abdelrhman132006@gmail.com>
PerchAnton <101743951+PerchAnton@users.noreply.github.com>
1 change: 1 addition & 0 deletions doc/manual/en/97-acknowledgement.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Omar Goher <omargoher59@gmail.com>
Rawan Essam <rawanessamwork@gmail.com>
Huda Amr <hudaamr2006@gmail.com>
AbdElrhman Mohamed <abdelrhman132006@gmail.com>
PerchAnton <101743951+PerchAnton@users.noreply.github.com>
```

## Committers
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
VITE_KEYCLOAK_URL=http://localhost:8180
VITE_KEYCLOAK_REALM=billetsys
VITE_KEYCLOAK_CLIENT_ID=billetsys-frontend

# Experimental public ticket references; opt in at frontend build time.
# See README.md. Existing ticket names and numeric IDs remain unchanged.
VITE_TICKET_REFERENCES=false
55 changes: 55 additions & 0 deletions src/frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,58 @@ The build output is written to `dist/`. Quarkus packages those files through Qui
- In development, Quinoa proxies `/app/*` to the Vite dev server on port `5173`.
- Top-level browser routes such as `/profile` are redirected by the backend to `/app/#...`, and `src/main.tsx` converts those locations back into normal client-side routes during app startup.
- Production packaging uses the `dist/` output generated from this frontend project. The legacy generated folder under `src/backend/main/resources/META-INF/resources/app` should not be used as the active frontend source anymore.

## Experimental ticket references

This optional UI uses RefMint `0.1.0-alpha.3` to encode a ticket's existing positive
numeric ID with a checksum. It does not allocate another ID or add a database
column, migration, index, endpoint or mapping table. Ticket names, numeric URLs,
and the existing search remain unchanged.

The controls are **off by default**. Set `VITE_TICKET_REFERENCES=true` in the
frontend build environment (or `.env` for local development), then rebuild or
restart Vite. Other values, including an unset variable, leave them hidden.
Disabled production builds omit the RefMint codec. Enabled builds load the
reference controls and codec on demand.
With it enabled, an existing ticket detail/form shows a copyable reference, and
the authenticated header for support, superuser, TAM and user accepts that reference in a separate **Open** field.
The decoder checks the code locally before opening the current role's existing
ticket route. Invalid input stays in the form with an error toast. Copy success
and clipboard errors use the application's toast notifications too.

The namespace is `btk`, codec kind `uint64`, format version `1`. Do not change
these after sharing references without planning compatibility. IDs must be
positive signed Java Long values. Exact decimal strings are supported through
`9223372036854775807`; unsafe JSON numbers are not displayed as references,
because their original value may already have been rounded by JavaScript.

A reference is **public, reversible and enumerable**. Its checksum helps detect
manual-entry mistakes; it is not encryption, authorization, an invitation, or a
secret ticket URL. Existing server authentication, role, company and participant
checks decide access. A valid code can still refer to a missing or inaccessible
ticket. Codes also do not identify an installation: use them only with the
Billetsys instance where they were issued.

RefMint is still alpha and is pinned exactly. Its v1 wire contract does not make
the package API or maintenance stable. Adopting it requires dependency and
format-lifecycle review. References are longer than small ticket numbers; no
user study has established faster lookup or a practical benefit for Billetsys.
Keep the feature disabled if the existing ticket names and search are sufficient.
The dependency is installed even when the UI is disabled.

Use Node.js 26 from `.nvmrc` for frontend checks (also used in CI). The dependency requires
Node.js 22 or newer, and the tests use Node's native TypeScript support:

```bash
npm ci
npm run test:references
npm run check
npm run build
```

The focused tests cover the four ticket-viewing roles and rejection of admin
(which has no ticket API permission), exact large IDs, input formatting,
checksum errors, foreign codes and malformed input. They do not replace a real
Keycloak/PostgreSQL permissions test. Before enabling the feature in production,
check missing tickets, denied tickets, cross-company access, and normal access
with each ticket-viewing role in the deployment's existing authorization setup.
10 changes: 10 additions & 0 deletions src/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
"lint": "eslint .",
"format": "prettier --write .",
"fix": "prettier --write . && eslint . --fix",
"check": "npm run lint && npm run typecheck && prettier --check .",
"prepare": "cd ../.. && husky"
"check": "npm run test:references && npm run lint && npm run typecheck && prettier --check .",
"prepare": "cd ../.. && husky",
"test:references": "node --test tests/ticket-references.test.mjs"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
Expand Down Expand Up @@ -73,6 +74,7 @@
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.16",
"react-router-dom": "^7.8.2",
"refmint": "0.1.0-alpha.3",
"rehype-highlight": "^7.0.2",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/components/layout/AuthenticatedHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
*/

import { TicketReferenceLookup } from "../tickets/OptionalTicketReferences";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useJson from "../../hooks/useJson";
Expand Down Expand Up @@ -385,6 +386,7 @@ export default function AuthenticatedHeader({
<div className="flex items-center gap-3 justify-end">
{session?.authenticated && (
<>
<TicketReferenceLookup role={role} />
<div ref={searchContainerRef} className="relative">
<div className="relative flex items-center">
<SearchIcon className="pointer-events-none absolute left-3 size-4 shrink-0 text-muted-foreground" />
Expand Down
37 changes: 37 additions & 0 deletions src/frontend/src/components/tickets/OptionalTicketReferences.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Eclipse Public License - v 2.0
*
* THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
* PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
*/

import { lazy, Suspense } from "react";
import type { Id, Role } from "../../types/app";

const Reference =
import.meta.env.VITE_TICKET_REFERENCES === "true"
? lazy(() => import("./TicketReference"))
: null;
const Lookup =
import.meta.env.VITE_TICKET_REFERENCES === "true"
? lazy(() => import("./TicketReferenceLookup"))
: null;

/** Load the optional reference display only when the build enables it. */
export function TicketReference({ ticketId }: { ticketId: Id }) {
return Reference ? (
<Suspense fallback={null}>
<Reference ticketId={ticketId} />
</Suspense>
) : null;
}

/** Load the optional lookup without adding its codec to disabled builds. */
export function TicketReferenceLookup({ role }: { role?: Role }) {
return Lookup ? (
<Suspense fallback={null}>
<Lookup role={role} />
</Suspense>
) : null;
}
49 changes: 49 additions & 0 deletions src/frontend/src/components/tickets/TicketReference.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Eclipse Public License - v 2.0
*
* THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
* PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
*/

import { useId } from "react";
import { toast } from "sonner";
import type { Id } from "../../types/app";
import { formatTicketReference } from "../../utils/ticketReferences";
import { Button } from "../ui/button";
import { Input } from "../ui/input";

/** An opt-in display alias; existing ticket names, links and stored IDs stay primary. */
export default function TicketReference({ ticketId }: { ticketId: Id }) {
const inputId = useId();
if (import.meta.env.VITE_TICKET_REFERENCES !== "true") return null;
const reference = formatTicketReference(ticketId);
if (!reference) return null;

async function copy(value: string) {
try {
await navigator.clipboard.writeText(value);
toast.success("Ticket reference copied.");
} catch {
toast.error("Select the reference above and copy it manually.");
}
}

return (
<div className="max-w-md space-y-1">
<label htmlFor={inputId} className="text-sm font-medium">
Ticket reference
</label>
<div className="flex gap-2">
<Input id={inputId} value={reference} readOnly className="font-mono" />
<Button
type="button"
variant="outline"
onClick={() => void copy(reference)}
>
Copy
</Button>
</div>
</div>
);
}
66 changes: 66 additions & 0 deletions src/frontend/src/components/tickets/TicketReferenceLookup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Eclipse Public License - v 2.0
*
* THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
* PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
*/

import { useState } from "react";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
import type { Role } from "../../types/app";
import { ticketReferencePath } from "../../utils/ticketReferences";
import { Button } from "../ui/button";
import { Input } from "../ui/input";

/** Check a manually entered alias before navigating to an existing protected route. */
export default function TicketReferenceLookup({ role }: { role?: Role }) {
const [value, setValue] = useState("");
const [invalid, setInvalid] = useState(false);
const navigate = useNavigate();
if (import.meta.env.VITE_TICKET_REFERENCES !== "true") return null;
if (!["support", "superuser", "tam", "user"].includes(role || ""))
return null;

return (
<form
aria-label="Find ticket by reference"
className="max-w-sm"
onSubmit={(event) => {
event.preventDefault();
const path = ticketReferencePath(value, role);
if (!path) {
setInvalid(true);
toast.error(
"Invalid ticket reference. Check the code and try again.",
);
return;
}
setInvalid(false);
setValue("");
navigate(path);
}}
>
<div className="flex gap-2">
<Input
aria-label="Ticket reference"
aria-invalid={invalid}
autoComplete="off"
spellCheck={false}
maxLength={128}
placeholder="Ticket reference"
value={value}
onChange={(event) => {
setValue(event.target.value);
setInvalid(false);
}}
className="w-44 font-mono text-foreground"
/>
<Button type="submit" variant="outline">
Open
</Button>
</div>
</form>
);
}
2 changes: 2 additions & 0 deletions src/frontend/src/pages/SupportTicketDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { ChangeEvent, FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { TicketReference } from "../components/tickets/OptionalTicketReferences";
import DataState from "../components/common/DataState";
import PageHeader from "../components/layout/PageHeader";
import MarkdownContent from "../components/markdown/MarkdownContent";
Expand Down Expand Up @@ -775,6 +776,7 @@ export default function SupportTicketDetailPage({
</AlertDescription>
</Alert>
)}
<TicketReference ticketId={ticket.id} />
<form onSubmit={saveTicket} className="space-y-8">
<div className="grid gap-5 md:grid-cols-2">
<Field>
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/src/pages/TicketWorkbenchFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { FormEvent } from "react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { TicketReference } from "../components/tickets/OptionalTicketReferences";
import DataState from "../components/common/DataState";
import PageHeader from "../components/layout/PageHeader";
import CustomFieldsFields from "../components/tickets/CustomFieldsFields";
Expand Down Expand Up @@ -272,6 +273,9 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) {
>
{bootstrap && formState && (
<form className="space-y-6 pb-20" onSubmit={submit}>
{bootstrap.ticket?.id != null && (
<TicketReference ticketId={bootstrap.ticket.id} />
)}
<Card>
<CardContent className="grid gap-6">
<Field>
Expand Down
58 changes: 58 additions & 0 deletions src/frontend/src/utils/ticketReferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Eclipse Public License - v 2.0
*
* THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
* PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
*/

import { createReferenceCodec } from "refmint/codec";
import { PATHS } from "../routes/paths";
import type { Id, Role } from "../types/app";

// This public, reversible alias is not an access token. Keep the namespace and
// v1 contract stable once an installation has shared a reference.
const tickets = createReferenceCodec({
prefix: "btk",
kind: "uint64",
version: 1,
});
const MAX_TICKET_ID = (1n << 63n) - 1n;

/** Encode the existing positive Java Long ID without rounding a JSON number. */
export function formatTicketReference(id: Id): string | null {
if (typeof id === "number" && (!Number.isSafeInteger(id) || id <= 0)) {
return null;
}
const decimal = String(id);
if (decimal.length > 19 || !/^[1-9][0-9]*$/.test(decimal)) {
return null;
}
const value = BigInt(decimal);
return value <= MAX_TICKET_ID ? tickets.format(tickets.encode(value)) : null;
}

/** Resolve only checked references, preserving the exact decimal ID in the URL. */
export function ticketReferencePath(
input: unknown,
role?: Role,
): string | null {
const decoded = tickets.decode(input);
if (!decoded.ok || decoded.value <= 0n || decoded.value > MAX_TICKET_ID) {
return null;
}
const id = decoded.value.toString();
// These are the existing authenticated routes. Their server-side role,
// company and participant checks remain the authority for ticket access.
switch (role) {
case "support":
return `${PATHS.supportTickets}/${id}`;
case "superuser":
return `${PATHS.superuserTickets}/${id}`;
case "tam":
case "user":
return `${PATHS.userTickets}/${id}`;
default:
return null;
}
}
Loading
Loading