Refactor hostname to become baseurl
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -154,7 +154,7 @@ export function MainLayout() {
|
||||
icon={IconBook}
|
||||
to={`${prefix}/obligations`}
|
||||
/>
|
||||
<SidebarItem
|
||||
<SidebarItem
|
||||
label={__("Continual Improvements")}
|
||||
icon={IconRotateCw}
|
||||
to={`${prefix}/continual-improvements`}
|
||||
@@ -192,15 +192,16 @@ export function MainLayout() {
|
||||
function UserDropdown({ organizationId }: { organizationId: string }) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const user = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, { organizationId }).viewer
|
||||
.user;
|
||||
const user = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {
|
||||
organizationId,
|
||||
}).viewer.user;
|
||||
|
||||
const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = async (
|
||||
e
|
||||
) => {
|
||||
e.preventDefault();
|
||||
|
||||
fetch(buildEndpoint("/auth/logout"), {
|
||||
fetch(buildEndpoint("/connect/logout"), {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -274,13 +275,19 @@ interface InvitationsResponse {
|
||||
invitations: Invitation[];
|
||||
}
|
||||
|
||||
function OrganizationSelectorWrapper({ organizationId }: { organizationId: string }) {
|
||||
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, { organizationId });
|
||||
function OrganizationSelectorWrapper({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {
|
||||
organizationId,
|
||||
});
|
||||
return <OrganizationSelector currentOrganization={data.organization} />;
|
||||
}
|
||||
|
||||
function OrganizationSelector({
|
||||
currentOrganization
|
||||
currentOrganization,
|
||||
}: {
|
||||
currentOrganization: MainLayoutQueryType["response"]["organization"];
|
||||
}) {
|
||||
@@ -297,32 +304,32 @@ function OrganizationSelector({
|
||||
|
||||
// Fetch organizations and invitations in parallel
|
||||
const [orgsResponse, invitationsResponse] = await Promise.all([
|
||||
fetch('/auth/organizations', { credentials: 'include' }),
|
||||
fetch('/auth/invitations', { credentials: 'include' })
|
||||
fetch("/connect/organizations", { credentials: "include" }),
|
||||
fetch("/connect/invitations", { credentials: "include" }),
|
||||
]);
|
||||
|
||||
if (!orgsResponse.ok) {
|
||||
throw new Error('Failed to fetch organizations');
|
||||
throw new Error("Failed to fetch organizations");
|
||||
}
|
||||
|
||||
if (!invitationsResponse.ok) {
|
||||
throw new Error('Failed to fetch invitations');
|
||||
throw new Error("Failed to fetch invitations");
|
||||
}
|
||||
|
||||
const orgsData: OrganizationsResponse = await orgsResponse.json();
|
||||
const invitationsData: InvitationsResponse = await invitationsResponse.json();
|
||||
const invitationsData: InvitationsResponse =
|
||||
await invitationsResponse.json();
|
||||
|
||||
// Count pending invitations (those without acceptedAt)
|
||||
const pendingCount = invitationsData.invitations.filter(
|
||||
inv => !inv.acceptedAt
|
||||
(inv) => !inv.acceptedAt
|
||||
).length;
|
||||
|
||||
setOrganizations(orgsData.organizations);
|
||||
setPendingInvitationsCount(pendingCount);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
console.error('Failed to fetch data:', err);
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
console.error("Failed to fetch data:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -334,11 +341,7 @@ function OrganizationSelector({
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className="-ml-3"
|
||||
variant="tertiary"
|
||||
disabled
|
||||
>
|
||||
<Button className="-ml-3" variant="tertiary" disabled>
|
||||
{__("Error loading organizations")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -355,7 +358,7 @@ function OrganizationSelector({
|
||||
iconAfter={IconChevronGrabberVertical}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? __("Loading...") : (currentOrganization?.name || "")}
|
||||
{isLoading ? __("Loading...") : currentOrganization?.name || ""}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -370,7 +373,8 @@ function OrganizationSelector({
|
||||
</div>
|
||||
) : (
|
||||
organizations.map((organization) => {
|
||||
const isAuthenticated = organization.authStatus === "authenticated";
|
||||
const isAuthenticated =
|
||||
organization.authStatus === "authenticated";
|
||||
const isExpired = organization.authStatus === "expired";
|
||||
const needsAuth = organization.authStatus === "unauthenticated";
|
||||
|
||||
@@ -378,16 +382,16 @@ function OrganizationSelector({
|
||||
? `/organizations/${organization.id}`
|
||||
: organization.loginUrl;
|
||||
|
||||
const isSAMLUrl = targetUrl.includes('/auth/saml/');
|
||||
const isSAMLUrl = targetUrl.includes("/connect/saml/");
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
asChild
|
||||
key={organization.id}
|
||||
>
|
||||
<DropdownItem asChild key={organization.id}>
|
||||
{isSAMLUrl ? (
|
||||
<a href={targetUrl} className="flex items-center gap-2">
|
||||
<Avatar name={organization.name} src={organization.logoUrl} />
|
||||
<Avatar
|
||||
name={organization.name}
|
||||
src={organization.logoUrl}
|
||||
/>
|
||||
<span className="flex-1">{organization.name}</span>
|
||||
{isAuthenticated && (
|
||||
<IconCheckmark1 size={16} className="text-green-600" />
|
||||
@@ -401,7 +405,10 @@ function OrganizationSelector({
|
||||
</a>
|
||||
) : (
|
||||
<Link to={targetUrl} className="flex items-center gap-2">
|
||||
<Avatar name={organization.name} src={organization.logoUrl} />
|
||||
<Avatar
|
||||
name={organization.name}
|
||||
src={organization.logoUrl}
|
||||
/>
|
||||
<span className="flex-1">{organization.name}</span>
|
||||
{isAuthenticated && (
|
||||
<IconCheckmark1 size={16} className="text-green-600" />
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function OrganizationsPage() {
|
||||
useEffect(() => {
|
||||
const fetchOrganizations = async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/organizations', {
|
||||
const response = await fetch('/connect/organizations', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function OrganizationsPage() {
|
||||
useEffect(() => {
|
||||
const fetchInvitations = async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/invitations', {
|
||||
const response = await fetch('/connect/invitations', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function OrganizationsPage() {
|
||||
const handleAcceptInvitation = async (invitationId: string, organizationId: string) => {
|
||||
setIsAccepting(true);
|
||||
try {
|
||||
const response = await fetch('/auth/invitations/accept', {
|
||||
const response = await fetch('/connect/invitations/accept', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -281,7 +281,7 @@ function OrganizationCard({ organization }: OrganizationCardProps) {
|
||||
};
|
||||
|
||||
// Check if the URL is a backend SAML endpoint
|
||||
const isSAMLUrl = targetUrl.includes('/auth/saml/');
|
||||
const isSAMLUrl = targetUrl.includes('/connect/saml/');
|
||||
|
||||
return (
|
||||
<Card padded className="w-full">
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function ConfirmEmailPage() {
|
||||
<p className="text-green-600 dark:text-green-400">
|
||||
{__("Your email has been confirmed successfully!")}
|
||||
</p>
|
||||
<Button onClick={() => navigate("/authentication/login")} className="w-full">
|
||||
<Button onClick={() => navigate("/auth/login")} className="w-full">
|
||||
{__("Proceed to Login")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@ export default function ConfirmEmailPage() {
|
||||
{!isConfirmed && (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
<Link
|
||||
to="/authentication/login"
|
||||
to="/auth/login"
|
||||
className="underline text-txt-primary hover:text-txt-secondary"
|
||||
>
|
||||
{__("Back to Login")}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const response = await fetch(
|
||||
buildEndpoint("/auth/forget-password"),
|
||||
buildEndpoint("/connect/forget-password"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -81,7 +81,7 @@ export default function ForgotPasswordPage() {
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Remember your password?")}{" "}
|
||||
<Link
|
||||
to="/authentication/login"
|
||||
to="/auth/login"
|
||||
className="underline text-txt-primary hover:text-txt-secondary"
|
||||
>
|
||||
{__("Back to login")}
|
||||
@@ -124,7 +124,7 @@ export default function ForgotPasswordPage() {
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Remember your password?")}{" "}
|
||||
<Link
|
||||
to="/authentication/login"
|
||||
to="/auth/login"
|
||||
className="underline text-txt-primary hover:text-txt-secondary"
|
||||
>
|
||||
{__("Back to login")}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(buildEndpoint("/auth/login"), {
|
||||
const res = await fetch(buildEndpoint("/connect/login"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -68,7 +68,7 @@ export default function LoginPage() {
|
||||
setIsChecking(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(buildEndpoint("/auth/check-sso"), {
|
||||
const res = await fetch(buildEndpoint("/connect/check-sso"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -85,7 +85,7 @@ export default function LoginPage() {
|
||||
|
||||
if (data.ssoAvailable && data.samlConfigId) {
|
||||
window.location.href = buildEndpoint(
|
||||
`/auth/saml/login/${data.samlConfigId}`
|
||||
`/connect/saml/login/${data.samlConfigId}`
|
||||
);
|
||||
} else {
|
||||
throw new Error(__("SSO not available for this email domain"));
|
||||
@@ -146,7 +146,7 @@ export default function LoginPage() {
|
||||
|
||||
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||
{__("Don't have an account ?")}{" "}
|
||||
<Link to="/authentication/register" className="underline hover:text-txt-primary">
|
||||
<Link to="/auth/register" className="underline hover:text-txt-primary">
|
||||
{__("Register")}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@ export default function LoginPage() {
|
||||
<div className="text-center text-sm text-txt-secondary">
|
||||
{__("Forgot password?")}{" "}
|
||||
<Link
|
||||
to="/authentication/forgot-password"
|
||||
to="/auth/forgot-password"
|
||||
className="underline hover:text-txt-primary"
|
||||
>
|
||||
{__("Reset password")}
|
||||
@@ -206,7 +206,7 @@ export default function LoginPage() {
|
||||
|
||||
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||
{__("Don't have an account ?")}{" "}
|
||||
<Link to="/authentication/register" className="underline hover:text-txt-primary">
|
||||
<Link to="/auth/register" className="underline hover:text-txt-primary">
|
||||
{__("Register")}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export default function LoginPage() {
|
||||
<div className="text-center text-sm text-txt-secondary">
|
||||
{__("Forgot password?")}{" "}
|
||||
<Link
|
||||
to="/authentication/forgot-password"
|
||||
to="/auth/forgot-password"
|
||||
className="underline hover:text-txt-primary"
|
||||
>
|
||||
{__("Reset password")}
|
||||
@@ -257,7 +257,7 @@ export default function LoginPage() {
|
||||
|
||||
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||
{__("Don't have an account ?")}{" "}
|
||||
<Link to="/authentication/register" className="underline hover:text-txt-primary">
|
||||
<Link to="/auth/register" className="underline hover:text-txt-primary">
|
||||
{__("Register")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function RegisterPage() {
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const response = await fetch(
|
||||
buildEndpoint("/auth/register"),
|
||||
buildEndpoint("/connect/register"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -106,7 +106,7 @@ export default function RegisterPage() {
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Already have an account?")}{" "}
|
||||
<Link
|
||||
to="/authentication/login"
|
||||
to="/auth/login"
|
||||
className="underline text-txt-primary hover:text-txt-secondary"
|
||||
>
|
||||
{__("Log in here")}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildEndpoint("/auth/reset-password"),
|
||||
buildEndpoint("/connect/reset-password"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -74,7 +74,7 @@ export default function ResetPasswordPage() {
|
||||
description: __("Password reset successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
navigate("/authentication/login", { replace: true });
|
||||
navigate("/auth/login", { replace: true });
|
||||
});
|
||||
|
||||
usePageTitle(__("Reset password"));
|
||||
@@ -118,7 +118,7 @@ export default function ResetPasswordPage() {
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Remember your password?")}{" "}
|
||||
<Link
|
||||
to="/authentication/login"
|
||||
to="/auth/login"
|
||||
className="underline text-txt-primary hover:text-txt-secondary"
|
||||
>
|
||||
{__("Log in here")}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function SignupFromInvitationPage() {
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildEndpoint("/auth/signup-from-invitation"),
|
||||
buildEndpoint("/connect/signup-from-invitation"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -125,7 +125,7 @@ export default function SignupFromInvitationPage() {
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Already have an account?")}{" "}
|
||||
<Link
|
||||
to="/authentication/login"
|
||||
to="/auth/login"
|
||||
className="underline text-txt-primary hover:text-txt-secondary"
|
||||
>
|
||||
{__("Log in here")}
|
||||
|
||||
@@ -50,7 +50,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
|
||||
const error = useRouteError() ?? propsError;
|
||||
|
||||
if (error instanceof UnAuthenticatedError) {
|
||||
return <Navigate to="/authentication/login" />;
|
||||
return <Navigate to="/auth/login" />;
|
||||
}
|
||||
|
||||
return <PageError error={error?.toString()} />;
|
||||
@@ -58,7 +58,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/authentication",
|
||||
path: "/auth",
|
||||
Component: AuthLayout,
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ unit:
|
||||
max-queue-size: 2048
|
||||
|
||||
probod:
|
||||
hostname: "https://gearnode.probo.engineering"
|
||||
base-url: "https://gearnode.probo.engineering"
|
||||
encryption-key: "thisisnotasecretAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
chrome-dp-addr: "localhost:9222"
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ unit:
|
||||
max-queue-size: 10000
|
||||
|
||||
probod:
|
||||
hostname: "localhost:8080"
|
||||
base-url: "http://localhost:8080"
|
||||
encryption-key: "base64-encoded-encryption-key"
|
||||
chrome-dp-addr: "localhost:9222"
|
||||
|
||||
@@ -249,11 +249,11 @@ Probod provides automatic structured JSON logging with:
|
||||
|
||||
### General Settings
|
||||
|
||||
#### `hostname` (string)
|
||||
#### `base-url` (string)
|
||||
|
||||
**Default**: `"localhost:8080"`
|
||||
**Default**: `"http://localhost:8080"`
|
||||
|
||||
The hostname and port where the Probod service will be accessible externally. This setting affects URL generation for redirects and API responses.
|
||||
The base URL where the Probod service will be accessible externally. This should include the scheme (http or https), hostname, and optionally port. This setting affects URL generation for emails, redirects, and API responses. For production deployments, use the full HTTPS URL (e.g., `"https://app.example.com"`).
|
||||
|
||||
#### `encryption-key` (string)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
var Templates embed.FS
|
||||
|
||||
const (
|
||||
logoURLFormat = "https://%s/logos/probo.png"
|
||||
logoURLPath = "/logos/probo.png"
|
||||
|
||||
subjectConfirmEmail = "Confirm your email address"
|
||||
subjectPasswordReset = "Reset your password"
|
||||
@@ -56,7 +56,7 @@ var (
|
||||
trustCenterAccessTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-access.txt.tmpl"))
|
||||
)
|
||||
|
||||
func RenderConfirmEmail(hostname, fullName, confirmationUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func RenderConfirmEmail(baseURL, fullName, confirmationUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
ConfirmationUrl string
|
||||
@@ -64,14 +64,14 @@ func RenderConfirmEmail(hostname, fullName, confirmationUrl string) (subject str
|
||||
}{
|
||||
FullName: fullName,
|
||||
ConfirmationUrl: confirmationUrl,
|
||||
LogoURL: fmt.Sprintf(logoURLFormat, hostname),
|
||||
LogoURL: baseURL + logoURLPath,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(confirmEmailTextTemplate, confirmEmailHTMLTemplate, data)
|
||||
return subjectConfirmEmail, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderPasswordReset(hostname, fullName, resetUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func RenderPasswordReset(baseURL, fullName, resetUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
ResetUrl string
|
||||
@@ -79,14 +79,14 @@ func RenderPasswordReset(hostname, fullName, resetUrl string) (subject string, t
|
||||
}{
|
||||
FullName: fullName,
|
||||
ResetUrl: resetUrl,
|
||||
LogoURL: fmt.Sprintf(logoURLFormat, hostname),
|
||||
LogoURL: baseURL + logoURLPath,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(passwordResetTextTemplate, passwordResetHTMLTemplate, data)
|
||||
return subjectPasswordReset, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderInvitation(hostname, fullName, organizationName, invitationUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func RenderInvitation(baseURL, fullName, organizationName, invitationUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
OrganizationName string
|
||||
@@ -96,14 +96,14 @@ func RenderInvitation(hostname, fullName, organizationName, invitationUrl string
|
||||
FullName: fullName,
|
||||
OrganizationName: organizationName,
|
||||
InvitationUrl: invitationUrl,
|
||||
LogoURL: fmt.Sprintf(logoURLFormat, hostname),
|
||||
LogoURL: baseURL + logoURLPath,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(invitationTextTemplate, invitationHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderDocumentSigning(hostname, fullName, organizationName, signingUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func RenderDocumentSigning(baseURL, fullName, organizationName, signingUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
OrganizationName string
|
||||
@@ -113,14 +113,14 @@ func RenderDocumentSigning(hostname, fullName, organizationName, signingUrl stri
|
||||
FullName: fullName,
|
||||
OrganizationName: organizationName,
|
||||
SigningUrl: signingUrl,
|
||||
LogoURL: fmt.Sprintf(logoURLFormat, hostname),
|
||||
LogoURL: baseURL + logoURLPath,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(documentSigningTextTemplate, documentSigningHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectDocumentSigning, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderDocumentExport(hostname, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func RenderDocumentExport(baseURL, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
DownloadUrl string
|
||||
@@ -128,14 +128,14 @@ func RenderDocumentExport(hostname, fullName, downloadUrl string) (subject strin
|
||||
}{
|
||||
FullName: fullName,
|
||||
DownloadUrl: downloadUrl,
|
||||
LogoURL: fmt.Sprintf(logoURLFormat, hostname),
|
||||
LogoURL: baseURL + logoURLPath,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(documentExportTextTemplate, documentExportHTMLTemplate, data)
|
||||
return subjectDocumentExport, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderFrameworkExport(hostname, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func RenderFrameworkExport(baseURL, fullName, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
data := struct {
|
||||
FullName string
|
||||
DownloadUrl string
|
||||
@@ -143,14 +143,14 @@ func RenderFrameworkExport(hostname, fullName, downloadUrl string) (subject stri
|
||||
}{
|
||||
FullName: fullName,
|
||||
DownloadUrl: downloadUrl,
|
||||
LogoURL: fmt.Sprintf(logoURLFormat, hostname),
|
||||
LogoURL: baseURL + logoURLPath,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(frameworkExportTextTemplate, frameworkExportHTMLTemplate, data)
|
||||
return subjectFrameworkExport, textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func RenderTrustCenterAccess(hostname, fullName, organizationName, accessUrl string, tokenDuration time.Duration) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func RenderTrustCenterAccess(baseURL, fullName, organizationName, accessUrl string, tokenDuration time.Duration) (subject string, textBody string, htmlBody *string, err error) {
|
||||
durationInDays := int(math.Round(tokenDuration.Hours() / 24))
|
||||
|
||||
data := struct {
|
||||
@@ -163,7 +163,7 @@ func RenderTrustCenterAccess(hostname, fullName, organizationName, accessUrl str
|
||||
FullName: fullName,
|
||||
OrganizationName: organizationName,
|
||||
AccessUrl: accessUrl,
|
||||
LogoURL: fmt.Sprintf(logoURLFormat, hostname),
|
||||
LogoURL: baseURL + logoURLPath,
|
||||
DurationInDays: durationInDays,
|
||||
}
|
||||
|
||||
|
||||
@@ -74,13 +74,13 @@ func (r AccessResult) ToError(baseURL string) error {
|
||||
case AuthMethodPassword:
|
||||
return ErrPasswordAuthRequired{
|
||||
OrganizationID: r.OrganizationID,
|
||||
RedirectURL: fmt.Sprintf("%s/authentication/login?method=password", baseURL),
|
||||
RedirectURL: fmt.Sprintf("%s/auth/login?method=password", baseURL),
|
||||
}
|
||||
case AuthMethodSAML, AuthMethodAny:
|
||||
return ErrSAMLAuthRequired{
|
||||
ConfigID: r.SAMLConfig.ID,
|
||||
OrganizationID: r.OrganizationID,
|
||||
RedirectURL: fmt.Sprintf("%s/auth/saml/login/%s", baseURL, r.SAMLConfig.ID),
|
||||
RedirectURL: fmt.Sprintf("%s/connect/saml/login/%s", baseURL, r.SAMLConfig.ID),
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("access denied to organization %s", r.OrganizationID)
|
||||
|
||||
@@ -224,11 +224,11 @@ func NewSAMLService(
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetEntityID() string {
|
||||
return fmt.Sprintf("%s/auth/saml/metadata", s.baseURL)
|
||||
return fmt.Sprintf("%s/connect/saml/metadata", s.baseURL)
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetAcsURL() string {
|
||||
return fmt.Sprintf("%s/auth/saml/consume", s.baseURL)
|
||||
return fmt.Sprintf("%s/connect/saml/consume", s.baseURL)
|
||||
}
|
||||
|
||||
func parseRawSAMLResponse(encodedResponse string) (*saml.Assertion, error) {
|
||||
@@ -564,7 +564,7 @@ func (s *SAMLService) HandleSAMLAssertion(
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetMetadataURL(organizationID gid.GID) string {
|
||||
return fmt.Sprintf("%s/auth/saml/metadata/%s", s.baseURL, organizationID)
|
||||
return fmt.Sprintf("%s/connect/saml/metadata/%s", s.baseURL, organizationID)
|
||||
}
|
||||
|
||||
func (s *SAMLService) GenerateMetadata() ([]byte, error) {
|
||||
|
||||
@@ -22,10 +22,10 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/baseurl"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
||||
@@ -40,7 +40,6 @@ type (
|
||||
pg *pg.Client
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
disableSignup bool
|
||||
@@ -51,7 +50,6 @@ type (
|
||||
pg *pg.Client
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
scope coredata.Scoper
|
||||
@@ -187,7 +185,6 @@ func NewService(
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
hp *passwdhash.Profile,
|
||||
tokenSecret string,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
disableSignup bool,
|
||||
invitationTokenValidity time.Duration,
|
||||
@@ -196,7 +193,6 @@ func NewService(
|
||||
pg: pgClient,
|
||||
encryptionKey: encryptionKey,
|
||||
hp: hp,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
tokenSecret: tokenSecret,
|
||||
disableSignup: disableSignup,
|
||||
@@ -209,7 +205,6 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthService {
|
||||
pg: s.pg,
|
||||
encryptionKey: s.encryptionKey,
|
||||
hp: s.hp,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
tokenSecret: s.tokenSecret,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
@@ -232,13 +227,17 @@ func (s Service) ForgetPassword(
|
||||
return fmt.Errorf("cannot generate password reset token: %w", err)
|
||||
}
|
||||
|
||||
resetPasswordUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/reset-password",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{passwordResetToken},
|
||||
}.Encode(),
|
||||
base, err := baseurl.Parse(s.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
resetPasswordUrl, err := base.
|
||||
WithPath("/auth/reset-password").
|
||||
WithQuery("token", passwordResetToken).
|
||||
String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build reset password URL: %w", err)
|
||||
}
|
||||
|
||||
return s.pg.WithConn(
|
||||
@@ -255,9 +254,9 @@ func (s Service) ForgetPassword(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderPasswordReset(
|
||||
s.hostname,
|
||||
s.baseURL,
|
||||
user.FullName,
|
||||
resetPasswordUrl.String(),
|
||||
resetPasswordUrl,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render password reset email: %w", err)
|
||||
@@ -352,19 +351,23 @@ func (s Service) SignUp(
|
||||
return fmt.Errorf("cannot generate confirmation token: %w", err)
|
||||
}
|
||||
|
||||
confirmationUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/confirm-email",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{confirmationToken},
|
||||
}.Encode(),
|
||||
base, err := baseurl.Parse(s.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
confirmationUrl, err := base.
|
||||
WithPath("/auth/confirm-email").
|
||||
WithQuery("token", confirmationToken).
|
||||
String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build confirmation URL: %w", err)
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderConfirmEmail(
|
||||
s.hostname,
|
||||
s.baseURL,
|
||||
user.FullName,
|
||||
confirmationUrl.String(),
|
||||
confirmationUrl,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render confirmation email: %w", err)
|
||||
|
||||
@@ -32,14 +32,14 @@ import (
|
||||
type (
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
invitationTokenValidity time.Duration
|
||||
}
|
||||
|
||||
TenantAuthzService struct {
|
||||
pg *pg.Client
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
invitationTokenValidity time.Duration
|
||||
scope coredata.Scoper
|
||||
@@ -62,13 +62,13 @@ const (
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
tokenSecret string,
|
||||
invitationTokenValidity time.Duration,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
tokenSecret: tokenSecret,
|
||||
invitationTokenValidity: invitationTokenValidity,
|
||||
}, nil
|
||||
@@ -77,7 +77,7 @@ func NewService(
|
||||
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
|
||||
return &TenantAuthzService{
|
||||
pg: s.pg,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
tokenSecret: s.tokenSecret,
|
||||
invitationTokenValidity: s.invitationTokenValidity,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
@@ -743,7 +743,7 @@ func (s *TenantAuthzService) InviteUserToOrganization(
|
||||
|
||||
if userExists {
|
||||
recipientName = user.FullName
|
||||
invitationURL = fmt.Sprintf("https://%s/", s.hostname)
|
||||
invitationURL = s.baseURL + "/"
|
||||
} else {
|
||||
recipientName = fullName
|
||||
invitationData := coredata.InvitationData{
|
||||
@@ -764,11 +764,11 @@ func (s *TenantAuthzService) InviteUserToOrganization(
|
||||
return fmt.Errorf("cannot generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
invitationURL = fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName))
|
||||
invitationURL = fmt.Sprintf("%s/auth/signup-from-invitation?token=%s&fullName=%s", s.baseURL, invitationToken, url.QueryEscape(fullName))
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderInvitation(
|
||||
s.hostname,
|
||||
s.baseURL,
|
||||
recipientName,
|
||||
organization.Name,
|
||||
invitationURL,
|
||||
|
||||
226
pkg/baseurl/baseurl.go
Normal file
226
pkg/baseurl/baseurl.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package baseurl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BaseURL represents a validated base URL for the application.
|
||||
// It provides convenient methods for building URLs with paths and query parameters.
|
||||
type BaseURL struct {
|
||||
raw string
|
||||
parsed *url.URL
|
||||
}
|
||||
|
||||
// Parse creates a new BaseURL from a string, validating that it's a valid absolute URL.
|
||||
func Parse(rawURL string) (*BaseURL, error) {
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("base URL cannot be empty")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.IsAbs() {
|
||||
return nil, fmt.Errorf("base URL must be absolute (include scheme)")
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("base URL scheme must be http or https, got: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
if parsed.Host == "" {
|
||||
return nil, fmt.Errorf("base URL must include a host")
|
||||
}
|
||||
|
||||
return &BaseURL{
|
||||
raw: rawURL,
|
||||
parsed: parsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MustParse creates a new BaseURL from a string, panicking if it's invalid.
|
||||
// This should only be used in tests or with known-valid URLs.
|
||||
func MustParse(rawURL string) *BaseURL {
|
||||
b, err := Parse(rawURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// String returns the base URL as a string.
|
||||
func (b *BaseURL) String() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.raw
|
||||
}
|
||||
|
||||
// Scheme returns the URL scheme (http or https).
|
||||
func (b *BaseURL) Scheme() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Scheme
|
||||
}
|
||||
|
||||
// Host returns the host:port portion of the URL.
|
||||
func (b *BaseURL) Host() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Host
|
||||
}
|
||||
|
||||
// Hostname returns just the hostname without the port.
|
||||
func (b *BaseURL) Hostname() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Hostname()
|
||||
}
|
||||
|
||||
// Port returns the port portion of the URL, or empty string if not specified.
|
||||
func (b *BaseURL) Port() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Port()
|
||||
}
|
||||
|
||||
// URLBuilder provides a fluent interface for building URLs.
|
||||
type URLBuilder struct {
|
||||
base *BaseURL
|
||||
path string
|
||||
query url.Values
|
||||
err error
|
||||
}
|
||||
|
||||
// WithPath returns a URLBuilder with the specified path.
|
||||
// The path will be properly joined with the base URL.
|
||||
func (b *BaseURL) WithPath(path string) *URLBuilder {
|
||||
if b == nil {
|
||||
return &URLBuilder{err: fmt.Errorf("base URL is nil")}
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
return &URLBuilder{
|
||||
base: b,
|
||||
path: path,
|
||||
query: make(url.Values),
|
||||
}
|
||||
}
|
||||
|
||||
// WithQuery adds a query parameter to the URL.
|
||||
func (ub *URLBuilder) WithQuery(key, value string) *URLBuilder {
|
||||
if ub.err != nil {
|
||||
return ub
|
||||
}
|
||||
ub.query.Add(key, value)
|
||||
return ub
|
||||
}
|
||||
|
||||
// WithQueryValues sets multiple query parameters at once.
|
||||
func (ub *URLBuilder) WithQueryValues(values url.Values) *URLBuilder {
|
||||
if ub.err != nil {
|
||||
return ub
|
||||
}
|
||||
for key, vals := range values {
|
||||
for _, val := range vals {
|
||||
ub.query.Add(key, val)
|
||||
}
|
||||
}
|
||||
return ub
|
||||
}
|
||||
|
||||
// String builds and returns the final URL string.
|
||||
func (ub *URLBuilder) String() (string, error) {
|
||||
if ub.err != nil {
|
||||
return "", ub.err
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: ub.base.Scheme(),
|
||||
Host: ub.base.Host(),
|
||||
Path: ub.path,
|
||||
RawQuery: ub.query.Encode(),
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// MustString builds and returns the final URL string, panicking on error.
|
||||
// This should only be used when you're certain the URL is valid.
|
||||
func (ub *URLBuilder) MustString() string {
|
||||
s, err := ub.String()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for BaseURL.
|
||||
func (b *BaseURL) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := Parse(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*b = *parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for BaseURL.
|
||||
func (b *BaseURL) MarshalJSON() ([]byte, error) {
|
||||
if b == nil {
|
||||
return json.Marshal("")
|
||||
}
|
||||
return json.Marshal(b.raw)
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler for BaseURL.
|
||||
func (b *BaseURL) UnmarshalText(text []byte) error {
|
||||
parsed, err := Parse(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*b = *parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalText implements encoding.TextMarshaler for BaseURL.
|
||||
func (b *BaseURL) MarshalText() ([]byte, error) {
|
||||
if b == nil {
|
||||
return []byte(""), nil
|
||||
}
|
||||
return []byte(b.raw), nil
|
||||
}
|
||||
243
pkg/baseurl/baseurl_test.go
Normal file
243
pkg/baseurl/baseurl_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package baseurl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid http URL",
|
||||
input: "http://localhost:8080",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid https URL",
|
||||
input: "https://example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid https URL with port",
|
||||
input: "https://example.com:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "relative URL",
|
||||
input: "/path/to/resource",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
input: "ftp://example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no host",
|
||||
input: "http://",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
input: "ht!tp://invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Parse(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got == nil {
|
||||
t.Error("Parse() returned nil without error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_Accessors(t *testing.T) {
|
||||
b := MustParse("https://example.com:8443")
|
||||
|
||||
if got := b.String(); got != "https://example.com:8443" {
|
||||
t.Errorf("String() = %v, want %v", got, "https://example.com:8443")
|
||||
}
|
||||
|
||||
if got := b.Scheme(); got != "https" {
|
||||
t.Errorf("Scheme() = %v, want %v", got, "https")
|
||||
}
|
||||
|
||||
if got := b.Host(); got != "example.com:8443" {
|
||||
t.Errorf("Host() = %v, want %v", got, "example.com:8443")
|
||||
}
|
||||
|
||||
if got := b.Hostname(); got != "example.com" {
|
||||
t.Errorf("Hostname() = %v, want %v", got, "example.com")
|
||||
}
|
||||
|
||||
if got := b.Port(); got != "8443" {
|
||||
t.Errorf("Port() = %v, want %v", got, "8443")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_WithPath(t *testing.T) {
|
||||
b := MustParse("https://example.com")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "path with leading slash",
|
||||
path: "/auth/login",
|
||||
want: "https://example.com/auth/login",
|
||||
},
|
||||
{
|
||||
name: "path without leading slash",
|
||||
path: "auth/login",
|
||||
want: "https://example.com/auth/login",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := b.WithPath(tt.path).String()
|
||||
if err != nil {
|
||||
t.Errorf("WithPath().String() error = %v", err)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("WithPath().String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_WithQuery(t *testing.T) {
|
||||
b := MustParse("https://example.com")
|
||||
|
||||
got, err := b.WithPath("/search").
|
||||
WithQuery("q", "test").
|
||||
WithQuery("limit", "10").
|
||||
String()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("WithPath().WithQuery().String() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse the result to check query parameters
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse result URL: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Query().Get("q") != "test" {
|
||||
t.Errorf("Query param 'q' = %v, want %v", parsed.Query().Get("q"), "test")
|
||||
}
|
||||
|
||||
if parsed.Query().Get("limit") != "10" {
|
||||
t.Errorf("Query param 'limit' = %v, want %v", parsed.Query().Get("limit"), "10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_WithQueryValues(t *testing.T) {
|
||||
b := MustParse("https://example.com")
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("foo", "bar")
|
||||
values.Add("baz", "qux")
|
||||
|
||||
got, err := b.WithPath("/test").WithQueryValues(values).String()
|
||||
if err != nil {
|
||||
t.Fatalf("WithPath().WithQueryValues().String() error = %v", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse result URL: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Query().Get("foo") != "bar" {
|
||||
t.Errorf("Query param 'foo' = %v, want %v", parsed.Query().Get("foo"), "bar")
|
||||
}
|
||||
|
||||
if parsed.Query().Get("baz") != "qux" {
|
||||
t.Errorf("Query param 'baz' = %v, want %v", parsed.Query().Get("baz"), "qux")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_JSON(t *testing.T) {
|
||||
original := MustParse("https://example.com:8443")
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var restored BaseURL
|
||||
if err := json.Unmarshal(data, &restored); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if restored.String() != original.String() {
|
||||
t.Errorf("After JSON round-trip: got %v, want %v", restored.String(), original.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_NilSafety(t *testing.T) {
|
||||
var b *BaseURL
|
||||
|
||||
if got := b.String(); got != "" {
|
||||
t.Errorf("nil.String() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Scheme(); got != "" {
|
||||
t.Errorf("nil.Scheme() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Host(); got != "" {
|
||||
t.Errorf("nil.Host() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Hostname(); got != "" {
|
||||
t.Errorf("nil.Hostname() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Port(); got != "" {
|
||||
t.Errorf("nil.Port() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
builder := b.WithPath("/test")
|
||||
if _, err := builder.String(); err == nil {
|
||||
t.Error("nil.WithPath().String() expected error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -457,9 +457,14 @@ func (s *DocumentService) SendSigningNotifications(
|
||||
return fmt.Errorf("cannot create signing request token: %w", err)
|
||||
}
|
||||
|
||||
baseURLParsed, err := url.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
signRequestURL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.svc.hostname,
|
||||
Scheme: baseURLParsed.Scheme,
|
||||
Host: baseURLParsed.Host,
|
||||
Path: "/documents/signing-requests",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{token},
|
||||
@@ -467,7 +472,7 @@ func (s *DocumentService) SendSigningNotifications(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderDocumentSigning(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
people.FullName,
|
||||
organization.Name,
|
||||
signRequestURL.String(),
|
||||
@@ -1584,7 +1589,7 @@ func (s *DocumentService) SendExportEmail(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderDocumentExport(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
recipientName,
|
||||
downloadURL,
|
||||
)
|
||||
|
||||
@@ -709,7 +709,7 @@ func (s FrameworkService) SendExportEmail(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderFrameworkExport(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
recipientName,
|
||||
downloadURL,
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ type (
|
||||
s3 *s3.Client
|
||||
bucket string
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
trustConfig TrustConfig
|
||||
agentConfig agents.Config
|
||||
@@ -70,7 +70,7 @@ type (
|
||||
bucket string
|
||||
encryptionKey cipher.EncryptionKey
|
||||
scope coredata.Scoper
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
trustConfig TrustConfig
|
||||
agent *agents.Agent
|
||||
@@ -115,7 +115,7 @@ func NewService(
|
||||
pgClient *pg.Client,
|
||||
s3Client *s3.Client,
|
||||
bucket string,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
tokenSecret string,
|
||||
trustConfig TrustConfig,
|
||||
agentConfig agents.Config,
|
||||
@@ -135,7 +135,7 @@ func NewService(
|
||||
s3: s3Client,
|
||||
bucket: bucket,
|
||||
encryptionKey: encryptionKey,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
tokenSecret: tokenSecret,
|
||||
trustConfig: trustConfig,
|
||||
agentConfig: agentConfig,
|
||||
@@ -156,7 +156,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
s3: s.s3,
|
||||
bucket: s.bucket,
|
||||
encryptionKey: s.encryptionKey,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
tokenSecret: s.tokenSecret,
|
||||
trustConfig: s.trustConfig,
|
||||
|
||||
@@ -442,7 +442,13 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
hostname := s.svc.hostname
|
||||
baseURLParsed, err := url.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := baseURLParsed.Host
|
||||
scheme := baseURLParsed.Scheme
|
||||
path := "/trust/" + trustCenter.Slug + "/access"
|
||||
|
||||
if organization.CustomDomainID != nil {
|
||||
@@ -456,11 +462,12 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con
|
||||
}
|
||||
|
||||
hostname = customDomain.Domain
|
||||
scheme = "https"
|
||||
path = "/access"
|
||||
}
|
||||
|
||||
accessURL := url.URL{
|
||||
Scheme: "https",
|
||||
Scheme: scheme,
|
||||
Host: hostname,
|
||||
Path: path,
|
||||
RawQuery: url.Values{
|
||||
@@ -489,7 +496,7 @@ func (s TrustCenterAccessService) sendTrustCenterAccessEmail(
|
||||
accessURL string,
|
||||
) error {
|
||||
subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
name,
|
||||
companyName,
|
||||
accessURL,
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/awsconfig"
|
||||
"github.com/getprobo/probo/pkg/baseurl"
|
||||
"github.com/getprobo/probo/pkg/certmanager"
|
||||
"github.com/getprobo/probo/pkg/connector"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
@@ -64,7 +65,7 @@ type (
|
||||
}
|
||||
|
||||
config struct {
|
||||
Hostname string `json:"hostname"`
|
||||
BaseURL *baseurl.BaseURL `json:"base-url"`
|
||||
EncryptionKey cipher.EncryptionKey `json:"encryption-key"`
|
||||
Pg pgConfig `json:"pg"`
|
||||
Api apiConfig `json:"api"`
|
||||
@@ -93,7 +94,7 @@ var (
|
||||
func New() *Implm {
|
||||
return &Implm{
|
||||
cfg: config{
|
||||
Hostname: "localhost:8080",
|
||||
BaseURL: baseurl.MustParse("http://localhost:8080"),
|
||||
Api: apiConfig{
|
||||
Addr: "localhost:8080",
|
||||
},
|
||||
@@ -275,8 +276,7 @@ func (impl *Implm) Run(
|
||||
impl.cfg.EncryptionKey,
|
||||
hp,
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
impl.cfg.Hostname,
|
||||
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.DisableSignup,
|
||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||
)
|
||||
@@ -287,7 +287,7 @@ func (impl *Implm) Run(
|
||||
authzService, err := authz.NewService(
|
||||
ctx,
|
||||
pgClient,
|
||||
impl.cfg.Hostname,
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||
)
|
||||
@@ -300,7 +300,7 @@ func (impl *Implm) Run(
|
||||
samlService, err := auth.NewSAMLService(
|
||||
pgClient,
|
||||
impl.cfg.EncryptionKey,
|
||||
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.SAML.SessionDurationTime(),
|
||||
impl.cfg.Auth.Cookie.Name,
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
@@ -347,7 +347,7 @@ func (impl *Implm) Run(
|
||||
pgClient,
|
||||
s3Client,
|
||||
impl.cfg.AWS.Bucket,
|
||||
impl.cfg.Hostname,
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
trustConfig,
|
||||
agentConfig,
|
||||
@@ -366,7 +366,7 @@ func (impl *Implm) Run(
|
||||
pgClient,
|
||||
s3Client,
|
||||
impl.cfg.AWS.Bucket,
|
||||
impl.cfg.Hostname,
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.EncryptionKey,
|
||||
impl.cfg.TrustAuth.TokenSecret,
|
||||
impl.cfg.GetSlackSigningSecret(),
|
||||
@@ -392,7 +392,7 @@ func (impl *Implm) Run(
|
||||
SAML: samlService,
|
||||
ConnectorRegistry: defaultConnectorRegistry,
|
||||
Agent: agent,
|
||||
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
|
||||
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.BaseURL.Host()},
|
||||
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
||||
FileManager: fileManagerService,
|
||||
PGClient: pgClient,
|
||||
|
||||
@@ -4997,12 +4997,12 @@ func (r *sAMLConfigurationResolver) SpMetadataURL(ctx context.Context, obj *type
|
||||
// TestLoginURL is the resolver for the testLoginUrl field.
|
||||
func (r *sAMLConfigurationResolver) TestLoginURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
|
||||
entityID := r.samlSvc.GetEntityID()
|
||||
parts := strings.Split(entityID, "/auth/saml/metadata")
|
||||
parts := strings.Split(entityID, "/connect/saml/metadata")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid entity ID format")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/auth/saml/login/%s", parts[0], obj.ID), nil
|
||||
return fmt.Sprintf("%s/connect/saml/login/%s", parts[0], obj.ID), nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
|
||||
@@ -56,7 +56,7 @@ func buildOrganizationResponse(
|
||||
// Generate logo URL path if organization has a logo
|
||||
var logoURL *string
|
||||
if org.LogoFileID != nil {
|
||||
url := fmt.Sprintf("/auth/organizations/%s/logo", org.ID)
|
||||
url := fmt.Sprintf("/connect/organizations/%s/logo", org.ID)
|
||||
logoURL = &url
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@ func buildOrganizationResponse(
|
||||
case authsvc.AuthMethodSAML, authsvc.AuthMethodAny:
|
||||
orgResponse.AuthenticationMethod = "saml"
|
||||
if accessResult.SAMLConfig != nil {
|
||||
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", accessResult.SAMLConfig.ID)
|
||||
orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", accessResult.SAMLConfig.ID)
|
||||
}
|
||||
case authsvc.AuthMethodPassword:
|
||||
orgResponse.AuthenticationMethod = "password"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
orgResponse.LoginURL = "/auth/login?method=password"
|
||||
}
|
||||
return orgResponse
|
||||
}
|
||||
@@ -88,13 +88,13 @@ func buildOrganizationResponse(
|
||||
|
||||
if sessionData.PasswordAuthenticated {
|
||||
orgResponse.AuthenticationMethod = "password"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
orgResponse.LoginURL = "/auth/login?method=password"
|
||||
} else if samlInfo, ok := sessionData.SAMLAuthenticatedOrgs[org.ID.String()]; ok {
|
||||
orgResponse.AuthenticationMethod = "saml"
|
||||
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", samlInfo.SAMLConfigID)
|
||||
orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", samlInfo.SAMLConfigID)
|
||||
} else {
|
||||
orgResponse.AuthenticationMethod = "any"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
orgResponse.LoginURL = "/auth/login?method=password"
|
||||
}
|
||||
|
||||
return orgResponse
|
||||
|
||||
@@ -135,7 +135,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
s.router.Mount("/api", s.apiServer)
|
||||
s.router.Mount("/auth", s.authServer)
|
||||
s.router.Mount("/connect", s.authServer)
|
||||
|
||||
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
|
||||
r.Use(s.loadTrustCenterBySlugOrID)
|
||||
|
||||
@@ -45,7 +45,7 @@ type (
|
||||
encryptionKey cipher.EncryptionKey
|
||||
tokenSecret string
|
||||
slackSigningSecret string
|
||||
hostname string
|
||||
baseURL string
|
||||
auth *auth.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
@@ -61,7 +61,7 @@ type (
|
||||
proboSvc *probo.Service
|
||||
encryptionKey cipher.EncryptionKey
|
||||
tokenSecret string
|
||||
hostname string
|
||||
baseURL string
|
||||
auth *auth.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
@@ -85,7 +85,7 @@ func NewService(
|
||||
pgClient *pg.Client,
|
||||
s3Client *s3.Client,
|
||||
bucket string,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
tokenSecret string,
|
||||
slackSigningSecret string,
|
||||
@@ -102,7 +102,7 @@ func NewService(
|
||||
encryptionKey: encryptionKey,
|
||||
tokenSecret: tokenSecret,
|
||||
slackSigningSecret: slackSigningSecret,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
auth: auth,
|
||||
html2pdfConverter: html2pdfConverter,
|
||||
fileManager: fileManagerService,
|
||||
@@ -120,7 +120,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
proboSvc: s.proboSvc,
|
||||
encryptionKey: s.encryptionKey,
|
||||
tokenSecret: s.tokenSecret,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
auth: s.auth,
|
||||
html2pdfConverter: s.html2pdfConverter,
|
||||
fileManager: s.fileManager,
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/baseurl"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/slack"
|
||||
@@ -409,6 +410,11 @@ func (s *SlackMessageService) buildAccessRequestMessage(
|
||||
fileIDs = append(fileIDs, file.ID)
|
||||
}
|
||||
|
||||
base, err := baseurl.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
templateData := struct {
|
||||
RequesterName string
|
||||
RequesterEmail string
|
||||
@@ -425,7 +431,7 @@ func (s *SlackMessageService) buildAccessRequestMessage(
|
||||
RequesterName: requesterName,
|
||||
RequesterEmail: requesterEmail,
|
||||
OrganizationID: organizationID.String(),
|
||||
Domain: s.svc.hostname,
|
||||
Domain: base.Host(),
|
||||
SlackMessageID: slackMessageID.String(),
|
||||
DocumentIDs: documentIDs,
|
||||
ReportIDs: reportIDs,
|
||||
|
||||
@@ -470,7 +470,13 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
hostname := s.svc.hostname
|
||||
baseURLParsed, err := url.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := baseURLParsed.Host
|
||||
scheme := baseURLParsed.Scheme
|
||||
path := "/trust/" + trustCenter.Slug + "/access"
|
||||
|
||||
if organization.CustomDomainID != nil {
|
||||
@@ -484,11 +490,12 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co
|
||||
}
|
||||
|
||||
hostname = customDomain.Domain
|
||||
scheme = "https"
|
||||
path = "/access"
|
||||
}
|
||||
|
||||
accessURL := url.URL{
|
||||
Scheme: "https",
|
||||
Scheme: scheme,
|
||||
Host: hostname,
|
||||
Path: path,
|
||||
RawQuery: url.Values{
|
||||
@@ -517,7 +524,7 @@ func (s *TrustCenterAccessService) sendTrustCenterAccessEmail(
|
||||
accessURL string,
|
||||
) error {
|
||||
subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
name,
|
||||
companyName,
|
||||
accessURL,
|
||||
|
||||
Reference in New Issue
Block a user