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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to Shelf will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).

## [2.2.0] — 2026-07-19

### Added

- **Open-source vs proprietary marker** — a product can be tagged *Open Source* or *Proprietary* (or left unspecified). The marker shows as a colored badge on the landing page, so closed-source product docs can be hosted and found alongside open-source ones and be clearly told apart. The landing page gains a matching filter. Purely a display dimension — orthogonal to visibility and access control.
- **Optional repository URL** — a product can carry a source-repository link. When set, the landing card shows a discreet “Source ↗” link; leave it empty and closed-source docs carry no repo link. Set both on the product form (General tab).

## [2.1.1] — 2026-07-19

### Fixed
Expand Down
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ Backend (`src/Cocoar.Shelf/`):
- `ShelfOptions.cs` — all configuration (incl. `ModgudOptions`)
- `Identity/` — ModgudUserProvisioning, ModgudClaimsTransformation, ClaimsSnapshot,
RbacCookiePreservation, AdminCheck, MartenUserStore
- `Models/` — ProductConfig (+`Restricted`, `ReadPrincipals`), Group, Principal
(`IPrincipal`/`PrincipalRef`), UserDocument (+claims snapshot), AccessLogEntry (+lat/lng)
- `Models/` — ProductConfig (+`Restricted`, `ReadPrincipals`, `Openness`, `RepositoryUrl`), Group,
Principal (`IPrincipal`/`PrincipalRef`), ProductOpenness (enum), UserDocument (+claims snapshot),
AccessLogEntry (+lat/lng)
- `Endpoints/` — ApiEndpoints (products/versions), AuthEndpoints, SettingsEndpoints,
UserEndpoints, AnalyticsEndpoints, GroupEndpoints, PrincipalEndpoints, TestAuthEndpoints, ApiKeyFilter
- `Middleware/DocsRoutingMiddleware.cs` — docs routing, rewriting, access-log recording
Expand All @@ -118,7 +119,11 @@ Frontend (`src/Cocoar.Shelf.Client/src/`):
- `layouts/AppLayout.vue` — one shared shell for landing + admin (header always; sidebar for
logged-in admins everywhere); `views/LandingView.vue` renders inside it
- `views/products/` — grid + tabbed edit modal (General / **Access** / Tags & API / Versions);
Access tab = Restricted toggle + principal picker (groups + users)
General tab = Openness (`ProductOpenness`) select + optional Repository URL; Access tab =
Restricted toggle + principal picker (groups + users)
- `views/LandingView.vue` — product cards show an **openness badge** (Open Source / Proprietary,
none when unspecified) + an optional "Source ↗" repo link; toolbar filters by tag AND openness
(`preferences.store` persists `opennessFilter`)
- `views/groups/` — Groups grid + tabbed modal (General / Members / Auto-membership with
`CoarScriptEditor` from `@cocoar/vue-script-editor` + dry-run)
- `views/admin/` — GeneralSettings (master key), Users, Groups, AccessLog, GeoIP;
Expand Down Expand Up @@ -150,3 +155,4 @@ Frontend (`src/Cocoar.Shelf.Client/src/`):
- **Restricted → 404, not 403** for authed-no-grant (no existence leak); restricted products are fully invisible (no teaser)
- **Analytics aggregation is server-side** — the browser only renders; country flags/names are derived client-side from the ISO code (display only)
- **modgud owns identity, Shelf owns authorization** — groups/grants reference products, which only exist in Shelf, so revocation is immediate (per request, not next login)
- **Openness is a display marker, not access control** — `ProductConfig.Openness` (Open Source / Proprietary / Unspecified) only drives a landing badge + filter; proprietary docs are hosted publicly and findable, just clearly labeled. Hide docs with `Restricted`, not `Openness`. The repo link lives in the product's own VitePress docs; Shelf adds only the optional `RepositoryUrl` "Source ↗" link — omit it for closed source
12 changes: 12 additions & 0 deletions src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/** Whether a product's source is public — display-only, drives a landing-page badge. */
export type ProductOpenness = 'Unspecified' | 'OpenSource' | 'Proprietary';

/** A principal (group or user) that a product's read access can be granted to. */
export type PrincipalKind = 'Group' | 'User';

Expand Down Expand Up @@ -26,6 +29,10 @@ export interface Product {
readPrincipals: PrincipalRef[];
tags: string[];
showWhenEmpty: boolean;
/** Open-source vs proprietary marker (display-only). */
openness: ProductOpenness;
/** Optional source-repository URL; when set the landing card shows a "Source ↗" link. */
repositoryUrl: string | null;
hasApiKey: boolean;
latest: string | null;
versions: string[];
Expand All @@ -47,6 +54,8 @@ export interface CreateProductRequest {
showWhenEmpty?: boolean;
restricted?: boolean;
readPrincipals?: PrincipalRef[];
openness?: ProductOpenness;
repositoryUrl?: string;
/** Per-product upload key. Write-only: responses only carry hasApiKey. */
apiKey?: string;
}
Expand All @@ -60,6 +69,9 @@ export interface UpdateProductRequest {
showWhenEmpty?: boolean;
restricted?: boolean;
readPrincipals?: PrincipalRef[];
openness?: ProductOpenness;
/** undefined = keep, '' = remove the repo link, value = set. */
repositoryUrl?: string;
/** undefined = keep, '' = remove, value = replace. */
apiKey?: string;
}
Expand Down
24 changes: 22 additions & 2 deletions src/Cocoar.Shelf.Client/src/stores/preferences.store.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import type { ProductOpenness } from '@/core/models/shelf.models';

const STORAGE_KEY = 'shelf:preferences';

/** Landing-page filter for the open-source vs proprietary marker; null = show all. */
export type OpennessFilter = 'OpenSource' | 'Proprietary' | null;

interface Preferences {
showPreview: boolean;
selectedTags: string[];
opennessFilter: OpennessFilter;
}

function load(): Preferences {
Expand All @@ -17,23 +22,26 @@ function load(): Preferences {
}

function defaults(): Preferences {
return { showPreview: false, selectedTags: [] };
return { showPreview: false, selectedTags: [], opennessFilter: null };
}

export const usePreferencesStore = defineStore('preferences', () => {
const saved = load();
const showPreview = ref(saved.showPreview);
const selectedTags = ref<string[]>(saved.selectedTags);
const opennessFilter = ref<OpennessFilter>(saved.opennessFilter);

function persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
showPreview: showPreview.value,
selectedTags: selectedTags.value,
opennessFilter: opennessFilter.value,
}));
}

watch(showPreview, persist);
watch(selectedTags, persist, { deep: true });
watch(opennessFilter, persist);

function toggleTag(tag: string) {
const idx = selectedTags.value.indexOf(tag);
Expand All @@ -45,5 +53,17 @@ export const usePreferencesStore = defineStore('preferences', () => {
selectedTags.value = [];
}

return { showPreview, selectedTags, toggleTag, clearTags };
// Click the active filter again to clear it (toggle behavior).
function toggleOpenness(value: Exclude<OpennessFilter, null>) {
opennessFilter.value = opennessFilter.value === value ? null : value;
}

return { showPreview, selectedTags, opennessFilter, toggleTag, clearTags, toggleOpenness };
});

/** Human label for an openness value (badge + filter). */
export function opennessLabel(openness: ProductOpenness): string {
return openness === 'OpenSource' ? 'Open Source'
: openness === 'Proprietary' ? 'Proprietary'
: '';
}
118 changes: 113 additions & 5 deletions src/Cocoar.Shelf.Client/src/views/LandingView.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="landing">
<div class="landing-toolbar" v-if="hasAnyPreviewContent || allTags.length > 0">
<div class="toolbar-tags" v-if="allTags.length > 0">
<div class="landing-toolbar" v-if="hasAnyPreviewContent || hasAnyOpenness || allTags.length > 0">
<div class="toolbar-tags" v-if="allTags.length > 0 || hasAnyOpenness">
<span class="toolbar-label">Filter:</span>
<button
v-for="tag in allTags"
Expand All @@ -11,6 +11,20 @@
@click="toggleTag(tag)"
>{{ tag }}</button>
<button v-if="selectedTags.length > 0" class="tag-clear" @click="clearTags">Clear</button>

<template v-if="hasAnyOpenness">
<span class="toolbar-divider" v-if="allTags.length > 0" aria-hidden="true"></span>
<button
class="tag-filter-chip openness-filter openness-filter--oss"
:class="{ active: opennessFilter === 'OpenSource' }"
@click="toggleOpenness('OpenSource')"
>Open Source</button>
<button
class="tag-filter-chip openness-filter openness-filter--prop"
:class="{ active: opennessFilter === 'Proprietary' }"
@click="toggleOpenness('Proprietary')"
>Proprietary</button>
</template>
</div>

<label class="filter-toggle" v-if="hasAnyPreviewContent">
Expand All @@ -31,11 +45,24 @@
<div class="card-title-row">
<div class="card-name">{{ product.displayName || product.name }}</div>
<span v-if="isPreviewProduct(product)" class="preview-badge">preview</span>
<span
v-if="product.openness && product.openness !== 'Unspecified'"
class="openness-badge"
:class="product.openness === 'OpenSource' ? 'openness-badge--oss' : 'openness-badge--prop'"
>{{ opennessLabel(product.openness) }}</span>
</div>
<div v-if="product.description" class="card-desc">{{ product.description }}</div>
<div v-if="product.tags && product.tags.length > 0" class="card-tags">
<span v-for="tag in product.tags" :key="tag" class="card-tag">{{ tag }}</span>
</div>
<a
v-if="product.repositoryUrl"
:href="product.repositoryUrl"
target="_blank"
rel="noopener"
class="card-source"
@click.stop
>Source ↗</a>
<div v-if="visibleVersions(product).length > 0" class="card-versions">
<a
v-for="v in visibleVersions(product)"
Expand All @@ -59,11 +86,23 @@
<div class="card-title-row">
<div class="card-name">{{ product.displayName || product.name }}</div>
<span v-if="product.visibility === 'preview'" class="preview-badge">preview</span>
<span
v-if="product.openness && product.openness !== 'Unspecified'"
class="openness-badge"
:class="product.openness === 'OpenSource' ? 'openness-badge--oss' : 'openness-badge--prop'"
>{{ opennessLabel(product.openness) }}</span>
</div>
<div v-if="product.description" class="card-desc">{{ product.description }}</div>
<div v-if="product.tags && product.tags.length > 0" class="card-tags">
<span v-for="tag in product.tags" :key="tag" class="card-tag">{{ tag }}</span>
</div>
<a
v-if="product.repositoryUrl"
:href="product.repositoryUrl"
target="_blank"
rel="noopener"
class="card-source"
>Source ↗</a>
<div class="card-coming-soon">Coming soon</div>
</div>
</template>
Expand All @@ -79,12 +118,12 @@
import { ref, computed, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import type { Product } from '@/core/models/shelf.models';
import { usePreferencesStore } from '@/stores/preferences.store';
import { usePreferencesStore, opennessLabel } from '@/stores/preferences.store';
import { useUI } from '@/composables/useUI';

const prefs = usePreferencesStore();
const { showPreview, selectedTags } = storeToRefs(prefs);
const { toggleTag, clearTags } = prefs;
const { showPreview, selectedTags, opennessFilter } = storeToRefs(prefs);
const { toggleTag, clearTags, toggleOpenness } = prefs;

const ui = useUI();
ui.set((ctx) => {
Expand Down Expand Up @@ -125,6 +164,11 @@ const hasAnyPreviewContent = computed(() =>
)
);

// Only surface the open-source/proprietary filter once at least one product is marked.
const hasAnyOpenness = computed(() =>
eligibleProducts.value.some(p => p.openness && p.openness !== 'Unspecified')
);

// All unique tags across all registered products (sorted)
const allTags = computed(() => {
const set = new Set<string>();
Expand All @@ -150,6 +194,10 @@ const visibleProducts = computed(() => {
);
}

if (opennessFilter.value) {
products = products.filter(p => p.openness === opennessFilter.value);
}

return products;
});

Expand Down Expand Up @@ -302,6 +350,66 @@ onMounted(async () => {
border: 1px solid var(--coar-border-semantic-warning-subtle, #fde68a);
}

.openness-badge {
font-size: 0.7em;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}

.openness-badge--oss {
background: var(--coar-background-semantic-success-subtle, #dcfce7);
color: var(--coar-text-semantic-success, #166534);
border: 1px solid var(--coar-border-semantic-success-subtle, #bbf7d0);
}

/* Proprietary = deliberately muted (no open source). */
.openness-badge--prop {
background: var(--coar-background-neutral-secondary);
color: var(--coar-text-neutral-secondary);
border: 1px solid var(--coar-border-neutral-tertiary);
}

.card-source {
margin-top: 12px;
align-self: flex-start;
font-size: 0.8em;
font-weight: 500;
color: var(--coar-text-neutral-tertiary);
text-decoration: none;
}

.card-source:hover {
color: var(--coar-text-accent-primary);
text-decoration: underline;
}

.toolbar-divider {
width: 1px;
align-self: stretch;
margin: 2px 4px;
background: var(--coar-border-neutral-tertiary);
}

.openness-filter::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}

.openness-filter--oss::before {
background: var(--coar-text-semantic-success, #16a34a);
}

.openness-filter--prop::before {
background: var(--coar-text-neutral-tertiary);
}

.card-desc {
font-size: 0.9em;
color: var(--coar-text-neutral-secondary);
Expand Down
Loading
Loading