Skip to content
Closed
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
13 changes: 13 additions & 0 deletions control-plane/src/main/java/io/reshapr/ctrl/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,17 @@ public String getUserId() {
public boolean verifyPassword(String challenged) {
return password != null && BcryptUtil.matches(challenged, password);
}

/**
* Ensure the user has a default organization when they belong to at least one.
* Called whenever a membership is added or reassigned.
*/
public void ensureDefaultOrganization() {
if (defaultOrganization != null) {
return;
}
if (organizations != null && !organizations.isEmpty()) {
defaultOrganization = organizations.getFirst();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ public Response changeOwner(@PathParam("organizationName") String organizationNa
// Assign organization to user.
if (!user.organizations.contains(organization)) {
user.organizations.add(organization);
userRepository.persistAndFlush(user);
}
user.ensureDefaultOrganization();
userRepository.persistAndFlush(user);
// Change owner on organization side.
organization.owner = user;
organizationRepository.persistAndFlush(organization);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,7 @@ public Response updateOrganizationOwner(@PathParam("username") String username,
if (user.organizations.stream().noneMatch(o -> o.name.equals(organizationName))) {
user.organizations.add(organization);
}
if (user.defaultOrganization == null) {
user.defaultOrganization = organization;
}
user.ensureDefaultOrganization();
userRepository.persistAndFlush(user);

return Response.ok(new OrganizationDTO(organizationName, organization.description, organization.icon)).build();
Expand All @@ -191,6 +189,7 @@ public Response assignMembership(@PathParam("username") String username, List<St
// Clear existing organizations and assign new ones.
user.organizations.clear();
user.organizations = organizationRepository.findByNames(organisationIds);
user.ensureDefaultOrganization();
userRepository.persistAndFlush(user);

return Response.ok(organisationIds).build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,12 @@ public Response callbackFromOidc(@QueryParam("code") String authorizationCode, @
}

// Generate a token for the authenticated user
String token = generateTokenForUser(RESHAPR_IDENTITY_PROVIDER, user, user.defaultOrganization.name);
Organization loginOrganization = resolveLoginOrganization(user);
if (loginOrganization == null) {
logger.warnf("User '%s' has no organization assigned", user.username);
return Response.status(Response.Status.FORBIDDEN).entity("User has no organization").build();
}
String token = generateTokenForUser(RESHAPR_IDENTITY_PROVIDER, user, loginOrganization.name);
logger.infof("Authentication successful for user: %s", user.username);

return Response.seeOther(URI.create(redirectUri + "?token=" + token)).build();
Expand Down Expand Up @@ -434,6 +439,16 @@ public record LoginRequest(String username, String password) {}

public record DelegatedLoginRequest(String username) {}

private Organization resolveLoginOrganization(User user) {
if (user.defaultOrganization != null) {
return user.defaultOrganization;
}
if (user.organizations != null && !user.organizations.isEmpty()) {
return user.organizations.getFirst();
}
return null;
}

private String generateTokenForUser(String authorityId, User user, String organizationId) {
// Generate a Jwt with user information.
String token = Jwt.issuer("https://app.reshapr.io")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,7 @@ public Organization createOrganization(String username, OrganizationInfo organiz
user.organizations = new ArrayList<>();
}
user.organizations.add(organization);
if (user.defaultOrganization == null) {
user.defaultOrganization = organization;
}
user.ensureDefaultOrganization();
userRepository.persistAndFlush(user);
return organization;
}
Expand Down
61 changes: 58 additions & 3 deletions dev/start-keycloak-docker.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,58 @@
docker run -it --rm -v $(pwd):/opt/keycloak/data/import -p 8888:8080 \
-e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.3.0 start-dev --hostname http://localhost:8888 --import-realm
#!/usr/bin/env bash
# Start Keycloak for local Reshapr OIDC dev (realm 3rdparty on port 8888).
# Single terminal: starts container, fixes master sslRequired, then follows logs.
set -euo pipefail

CONTAINER_NAME="${RESHAPR_KEYCLOAK_CONTAINER:-reshapr-keycloak-dev}"
KEYCLOAK_IMAGE="${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.3.0}"
HOST_PORT="${KEYCLOAK_HOST_PORT:-8888}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"

docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true

echo "Starting Keycloak (${CONTAINER_NAME}) on http://localhost:${HOST_PORT} ..."
docker run -d --rm --name "${CONTAINER_NAME}" \
-v "${SCRIPT_DIR}:/opt/keycloak/data/import" \
-p "${HOST_PORT}:8080" \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
-e KC_HOSTNAME=localhost \
-e KC_HOSTNAME_STRICT=false \
-e KC_HTTP_ENABLED=true \
"${KEYCLOAK_IMAGE}" \
start-dev --hostname "http://localhost:${HOST_PORT}" --import-realm

echo "Waiting for Keycloak to be ready ..."
READY=0
for i in $(seq 1 90); do
if curl -sf "http://localhost:${HOST_PORT}/realms/3rdparty" >/dev/null 2>&1; then
READY=1
break
fi
printf '.'
sleep 2
done
echo ""

if [ "${READY}" -ne 1 ]; then
echo "Keycloak did not become ready in time. Logs:" >&2
docker logs "${CONTAINER_NAME}" 2>&1 | tail -50 >&2
exit 1
fi

echo "Disabling SSL requirement on master realm (local dev only) ..."
docker exec "${CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh config credentials \
--server "http://localhost:8080" --realm master --user admin --password admin

docker exec "${CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE

echo ""
echo "Keycloak ready:"
echo " Admin console: http://localhost:${HOST_PORT}/admin (admin / admin)"
echo " Realm OIDC: 3rdparty (client reshapr-ctrl, user laurent / laurent)"
echo " Stop: docker stop ${CONTAINER_NAME}"
echo ""
echo "Following logs (Ctrl+C stops tail only; container keeps running until docker stop):"
docker logs -f "${CONTAINER_NAME}"
173 changes: 173 additions & 0 deletions web-ui/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Copyright The Reshapr Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ApiError } from './errors.js';

export { ApiError } from './errors.js';

async function parseErrorBody(res: Response): Promise<string> {
const t = await res.text();
return t || res.statusText;
}

/**
* REST client for control-plane v1 APIs via the SvelteKit BFF proxy.
* Authentication is handled server-side (httpOnly cookie); no Bearer header in the browser.
*/
export function apiClient() {
const json = async <T>(path: string, init?: RequestInit): Promise<T> => {
const res = await fetch(path, init);
if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status);
if (res.status === 204) return undefined as T;
const ct = res.headers.get('content-type');
if (ct?.includes('application/json')) return res.json() as Promise<T>;
return (await res.text()) as T;
};

const empty = async (path: string, init?: RequestInit) => {
const res = await fetch(path, init);
if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status);
};

return {
listServices: () => json<unknown[]>('/api/v1/services?page=0&size=500'),
listServicesPage: (page: number, size: number) =>
json<unknown[]>(`/api/v1/services?page=${page}&size=${size}`),
getService: (id: string) => json<unknown>(`/api/v1/services/${id}`),
deleteService: (id: string) => empty(`/api/v1/services/${id}`, { method: 'DELETE' }),

listArtifactsByService: (serviceId: string) =>
json<unknown[]>(`/api/v1/artifacts/service/${serviceId}`),

importArtifactFile: async (file: File, extra?: Record<string, string>) => {
const fd = new FormData();
fd.append('file', file);
fd.append('mainArtifact', 'true');
if (extra?.serviceName) fd.append('serviceName', extra.serviceName);
if (extra?.serviceVersion) fd.append('serviceVersion', extra.serviceVersion);
const res = await fetch('/api/v1/artifacts', { method: 'POST', body: fd });
if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status);
return res.json();
},

importArtifactUrl: async (params: URLSearchParams) => {
const res = await fetch('/api/v1/artifacts', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status);
return res.json();
},

attachArtifactFile: async (file: File) => {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/v1/artifacts/attach', { method: 'POST', body: fd });
if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status);
return res.json();
},

attachArtifactUrl: async (url: string, secretName?: string) => {
const p = new URLSearchParams();
p.set('url', url);
if (secretName) p.set('secretName', secretName);
const res = await fetch('/api/v1/artifacts/attach', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: p.toString()
});
if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status);
return res.json();
},

listConfigurationPlans: () => json<unknown[]>('/api/v1/configurationPlans'),
getConfigurationPlan: (id: string) => json<unknown>(`/api/v1/configurationPlans/${id}`),
createConfigurationPlan: (body: unknown) =>
json<unknown>('/api/v1/configurationPlans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
updateConfigurationPlan: (id: string, body: unknown) =>
json<unknown>(`/api/v1/configurationPlans/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
deleteConfigurationPlan: (id: string) =>
empty(`/api/v1/configurationPlans/${id}`, { method: 'DELETE' }),
renewApiKey: (id: string) =>
json<unknown>(`/api/v1/configurationPlans/${id}/renewApiKey`, { method: 'PUT' }),

listExpositionsAll: () => json<unknown[]>('/api/v1/expositions'),
listExpositionsActive: () => json<unknown[]>('/api/v1/expositions/active'),
getExposition: (id: string) => json<unknown>(`/api/v1/expositions/${id}`),
getActiveExposition: (id: string) => json<unknown>(`/api/v1/expositions/active/${id}`),
getActiveExpositionOrNull: async (id: string): Promise<unknown | null> => {
const res = await fetch(`/api/v1/expositions/active/${id}`);
if (res.status === 404) return null;
if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status);
return res.json() as Promise<unknown>;
},
createExposition: (body: { configurationPlanId: string; gatewayGroupId: string }) =>
json<unknown>('/api/v1/expositions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
deleteExposition: (id: string) => empty(`/api/v1/expositions/${id}`, { method: 'DELETE' }),

listSecretRefs: () => json<unknown[]>('/api/v1/secrets/refs?page=0&size=500'),
listSecrets: (page = 0, size = 500) =>
json<unknown[]>(`/api/v1/secrets?page=${page}&size=${size}`),
getSecret: (id: string) => json<unknown>(`/api/v1/secrets/${id}`),
createSecret: (body: unknown) =>
json<unknown>('/api/v1/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
updateSecret: (id: string, body: unknown) =>
json<unknown>(`/api/v1/secrets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
deleteSecret: (id: string) => empty(`/api/v1/secrets/${id}`, { method: 'DELETE' }),

listGatewayGroups: () => json<unknown[]>('/api/v1/gatewayGroups'),
createGatewayGroup: (body: unknown) =>
json<unknown>('/api/v1/gatewayGroups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
deleteGatewayGroup: (id: string) => empty(`/api/v1/gatewayGroups/${id}`, { method: 'DELETE' }),

getQuotas: () => json<unknown>('/api/v1/quotas'),

listApiTokens: () => json<unknown[]>('/api/v1/tokens/apiTokens'),
createApiToken: (body: unknown) =>
json<unknown>('/api/v1/tokens/apiTokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
deleteApiToken: (tokenId: string) =>
empty(`/api/v1/tokens/apiTokens/${tokenId}`, { method: 'DELETE' })
};
}
27 changes: 27 additions & 0 deletions web-ui/src/lib/api/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright The Reshapr Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export class ApiError extends Error {
readonly status: number;
readonly body: string | undefined;

constructor(message: string, status: number, body?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
26 changes: 26 additions & 0 deletions web-ui/src/lib/components/ApiErrorAlert.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!--
~ Copyright The Reshapr Authors.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->

<script lang="ts">
import * as Alert from '$lib/components/ui/alert/index.js';

let { message }: { message: string } = $props();
</script>

<Alert.Root variant="destructive">
<Alert.Title>Error</Alert.Title>
<Alert.Description class="whitespace-pre-wrap font-mono text-xs">{message}</Alert.Description>
</Alert.Root>
Loading