Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.pnpm-store/
# TypeScript cache
*.tsbuildinfo
+2
View File
@@ -0,0 +1,2 @@
auto-install-peers=true
public-hoist-pattern[]=*
+45
View File
@@ -0,0 +1,45 @@
SHELL := bash
NAME := idp
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
include ../../.bingo/Variables.mk
endif
include ../../.make/default.mk
include ../../.make/go.mk
include ../../.make/release.mk
include ../../.make/docs.mk
.PHONY: node-generate-prod
node-generate-prod: assets
.PHONY: assets
assets: pnpm-build \
assets/identifier/static/favicon.svg \
assets/identifier/static/icon-lilac.svg
.PHONY: assets/identifier/static/favicon.svg # force overwrite
assets/identifier/static/favicon.svg: pnpm-build
cp src/images/favicon.svg assets/identifier/static/favicon.svg
rm assets/identifier/static/favicon.ico
.PHONY: assets/identifier/static/icon-lilac.svg
assets/identifier/static/icon-lilac.svg: pnpm-build
cp src/images/icon-lilac.svg assets/identifier/static/icon-lilac.svg
.PHONY: pnpm-build
pnpm-build: node_modules
pnpm build
.PHONY: node_modules
node_modules:
pnpm install
.PHONY: ci-node-check-licenses
ci-node-check-licenses: node_modules
pnpm licenses:check
.PHONY: ci-node-save-licenses
ci-node-save-licenses: node_modules
pnpm licenses:csv
pnpm licenses:save
+82
View File
@@ -0,0 +1,82 @@
# IDP
This service provides a builtin minimal OpenID Connect provider based on [LibreGraph Connect (lico)](https://github.com/libregraph/lico) for КуСфера.
It is mainly targeted at smaller installations. For larger setups it is recommended to replace IDP with an external OpenID Connect Provider.
By default, it is configured to use the КуСфера IDM service as its LDAP backend for looking up and authenticating users. Other backends like an external LDAP server can be configured via a set of [enviroment variables](https://docs.qsfera.eu/docs/dev/server/services/idp/environment-variables).
Note that translations provided by the IDP service are not maintained via КуСфера but part of the embedded [LibreGraph Connect Identifier](https://github.com/libregraph/lico/tree/master/identifier) package.
## Configuration
### Custom Clients
By default the `idp` service generates a OIDC client configuration suitable for
using КуСфера with the standard client applications (Web, Desktop, iOS and
Android). If you need to configure additional client it is possible to inject a
custom configuration via `yaml`. This can be done by adding a section `clients`
to the `idp` section of the main configuration file (`qsfera.yaml`). This section
needs to contain configuration for all clients (including the standard clients).
For example if you want to add a (public) client for use with the oidc-agent you would
need to add this snippet to the `idp` section in `qsfera.yaml`.
```yaml
clients:
- id: web
name: КуСфера Web App
trusted: true
secret: ""
redirect_uris:
- https://qsfera.k8s:9200/
- https://qsfera.k8s:9200/oidc-callback.html
- https://qsfera.k8s:9200/oidc-silent-redirect.html
post_logout_redirect_uris: []
origins:
- https://qsfera.k8s:9200
application_type: ""
- id: QsferaDesktop
name: КуСфера Desktop Client
trusted: false
secret: ""
redirect_uris:
- http://127.0.0.1
- http://localhost
post_logout_redirect_uris: []
origins: []
application_type: native
- id: QsferaAndroid
name: КуСфера Android App
trusted: false
secret: ""
redirect_uris:
- oc://android.qsfera.eu
post_logout_redirect_uris:
- oc://android.qsfera.eu
origins: []
application_type: native
- id: QsferaIOS
name: КуСфера iOS App
trusted: false
secret: ""
redirect_uris:
- oc://ios.qsfera.eu
post_logout_redirect_uris:
- oc://ios.qsfera.eu
origins: []
application_type: native
- id: oidc-agent
name: OIDC Agent
trusted: false
secret: ""
redirect_uris:
- http://127.0.0.1
- http://localhost
post_logout_redirect_uris: []
origins: []
application_type: native
```
+2
View File
@@ -0,0 +1,2 @@
*.mo
dev-translation.json
+57
View File
@@ -0,0 +1,57 @@
# Tools
PNPM ?= pnpm
MSGCAT ?= msgcat
MSGMERGE ?= msgmerge
MSGFMT ?= msgfmt
# Variables
POT = translation.pot
POS ?= $(wildcard *.po)
# Translations
.PHONY: build
build: json
.PYTHON: json
json:
@for po in $(POS); do \
lang=$$(echo $$po | sed "s/\.po//"); \
$(PNPM) i18next-conv -K --skipUntranslated -l $$lang -s i18n/$$po -t ../src/locales/$$lang/translation.json; \
done
$(PNPM) node i18n/build-json.js ../src/locales/locales.json $(POS)
dev-translation.json: FORCE
$(PNPM) i18next --fail-on-warnings
.PHONY: extract
extract: pot
.PHONY: pot
pot: dev-translation.json
@tmpfile1=$(shell mktemp).po; \
tmpfile2=$(shell mktemp).po; \
trap 'rm -f "$$tmpfile1" "$$tmpfile2"' EXIT; \
$(PNPM) i18next-conv --project "LibreGraph Connect Identifier" -K -l en -s i18n/dev-translation.json -t $$tmpfile1; \
$(PNPM) node i18n/build-pot.js $$tmpfile1 $$tmpfile2; \
$(MSGCAT) --no-wrap -o $(POT) $$tmpfile2
.PHONY: merge
merge: $(POS)
$(POS): FORCE $(POT)
@echo -n "$@ " && \
$(MSGMERGE) -U \
--backup=none \
--no-wrap \
--sort-output \
$@ $(POT)
.PHONY: stats
stats:
$(foreach po, $(POS), $(shell $(MSGFMT) -v --statistics $(po)))
@- true
FORCE:
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
const cldr = require('cldr');
if (process.argv.length < 4) {
throw new Error('usage error: at least 2 arguments are required!');
}
const output = process.argv[2];
const pos = process.argv.slice(3);
const englishLanguageDisplayNames = cldr.extractLanguageDisplayNames('en');
const englishTerritoryDisplayNames = cldr.extractTerritoryDisplayNames('en');
function localeCapitalize(s, locale) {
return s.charAt(0).toLocaleUpperCase(locale) + s.slice(1)
}
function Locale(locale, overrides={}) {
let ietf = null;
let [code, country] = locale.split('-', 2);
switch(locale) {
// Additional mapping.
case 'zh-CN':
code = 'zh_hans';
ietf = code;
country = null;
break;
case 'zh-TW':
code = 'zh_hant';
ietf = code;
country = null;
break;
default:
}
overrides = ietf ? {
ietf,
...overrides,
} : overrides;
const languageDisplayNames = cldr.extractLanguageDisplayNames(code);
if (languageDisplayNames) {
let name = localeCapitalize(englishLanguageDisplayNames[code], 'en');
let nativeName = localeCapitalize(languageDisplayNames[code], locale);
if (name && nativeName) {
if (country) {
let countryNative = localeCapitalize(cldr.extractTerritoryDisplayNames(code)[country], locale);
nativeName = `${nativeName} (${countryNative})`;
name = `${name} (${localeCapitalize(englishTerritoryDisplayNames[country], 'en')})`;
}
return {
locale,
name,
nativeName,
...cldr.extractLayout(code),
...overrides,
}
}
}
}
var locales = [
Locale('en-GB', { name: 'English', nativeName: 'English' }), // Always add en-GB as English.
]
pos.map((po) => {
const locale = Locale(po.replace(/\.[^/.]+$/, ''));
if (locale) {
locales.push(locale);
}
});
locales.sort((a, b) => {
return a.locale > b.locale ? 1 : -1;
})
require('fs').writeFileSync(output, JSON.stringify(locales, null, 2));
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
var gettextParser = require("gettext-parser");
const args = process.argv.slice(2);
var input = require('fs').readFileSync(args[0]);
var po = gettextParser.po.parse(input);
Object.entries(po.translations[""]).map(([context, v]) => {
if (v.msgid) {
if (!v.comments) {
v.comments = {};
}
v.comments.extracted = "From: " + (v.comments.reference || '');
}
});
delete po.headers["PO-Revision-Date"];
delete po.headers["Language"];
delete po.headers["Plural-Forms"];
delete po.headers["mime-version"];
po.headers["MIME-Version"] = "1.0";
var output = gettextParser.po.compile(po);
require('fs').writeFileSync(args[1], output);
+224
View File
@@ -0,0 +1,224 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-03-09T19:33:04.641Z\n"
"PO-Revision-Date: 2022-03-10 09:40+0100\n"
"Last-Translator: Simon Eisenmann <simon@longsleep.org>\n"
"Language-Team: German <http://translate.kopano.io/projects/kopano/konnect/de/>\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Poedit 2.3\n"
#. From: konnect##consent##message
#: konnect##consent##message
msgid "<0><0><0></0></0></0> wants to"
msgstr "<0><0><0></0></0></0> möchte"
#. From: konnect##chooseaccount##useOther##persona##label
#: konnect##chooseaccount##useOther##persona##label
msgid "?"
msgstr "?"
#. From: konnect##scopeDescription##aliasBasic
#: konnect##scopeDescription##aliasBasic
msgid "Access your basic account information"
msgstr "Zugriff auf Ihre grundlegenden Benutzerinformationen"
#. From: konnect##consent##allowButton##label
#: konnect##consent##allowButton##label
msgid "Allow"
msgstr "Einverstanden"
#. From: konnect##consent##question
#: konnect##consent##question
msgid "Allow <1><0></0></1> to do this?"
msgstr "<1><0></0></1> den Zugriff gestatten?"
#. From: konnect##consent##consequence
#: konnect##consent##consequence
msgid "By clicking Allow, you allow this app to use your information."
msgstr "Wenn Sie Einverstanden klicken, erhält die App Zugriff auf Ihre Informationen."
#. From: konnect##consent##cancelButton##label
#: konnect##consent##cancelButton##label
msgid "Cancel"
msgstr "Abbrechen"
#. From: konnect##chooseaccount##headline
#: konnect##chooseaccount##headline
msgid "Choose an account"
msgstr "Konto auswählen"
#. From: konnect##consent##tooltip##client
#: konnect##consent##tooltip##client
msgid "Clicking \"Allow\" will redirect you to: {{redirectURI}}"
msgstr "Wenn Sie \"Einverstanden\" klicken werden Sie zu {{redirectURI}} weitergeleitet"
#. From: konnect##login##usernameField##placeholder##email
#: konnect##login##usernameField##placeholder##email
msgid "Email"
msgstr "E-Mail"
#. From: konnect##error##login##validate##missingUsername
#: konnect##error##login##validate##missingUsername
msgid "Enter a valid value."
msgstr "Geben Sie einen gültigen Wert ein."
#. From: konnect##error##login##validate##missingPassword
#: konnect##error##login##validate##missingPassword
msgid "Enter your password."
msgstr "Geben Sie ein Passwort ein."
#. From: konnect##loading##error##headline
#: konnect##loading##error##headline
msgid "Failed to connect to server"
msgstr "Verbindung zum Server fehlgeschlagen"
#. From: konnect##goodbye##headline
#: konnect##goodbye##headline
msgid "Goodbye"
msgstr "Bis bald"
#. From: konnect##goodbye##confirm##headline
#: konnect##goodbye##confirm##headline
msgid "Hello {{displayName}}"
msgstr "Hallo {{displayName}}"
#. From: konnect##consent##headline
#: konnect##consent##headline
msgid "Hi {{displayName}}"
msgstr "Hallo {{displayName}}"
#. From: konnect##login##usernameField##placeholder##identity
#: konnect##login##usernameField##placeholder##identity
msgid "Identity"
msgstr "Identität"
#. From: konnect##scopeDescription##offlineAccess
#: konnect##scopeDescription##offlineAccess
msgid "Keep the allowed access persistently and forever"
msgstr "Dauerhaften Zugriff (läuft nicht ab)"
#. From: konnect##error##login##failed
#: konnect##error##login##failed
msgid "Logon failed. Please verify your credentials and try again."
msgstr "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingabe und versuchen Sie es noch einmal."
#. From: konnect##error##http##networkError
#: konnect##error##http##networkError
msgid "Network error. Please check your connection and try again."
msgstr "Netzwerkfehler. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es noch einmal."
#. From: konnect##login##nextButton##label
#: konnect##login##nextButton##label
msgid "Next"
msgstr "Weiter"
#. From: konnect##login##passwordField##label
#: konnect##login##passwordField##label
msgid "Password"
msgstr "Passwort"
#. From: konnect##goodbye##message##confirm
#: konnect##goodbye##message##confirm
msgid "Press the button below, to sign out from your account now."
msgstr "Klicken Sie auf die Schaltfläche unten um sich aus Ihrem Konto abzumelden."
#. From: konnect##login##retryButton##label
#: konnect##login##retryButton##label
msgid "Retry"
msgstr "Wiederholen"
#. From: konnect##scopeDescription##scope
#: konnect##scopeDescription##scope
msgid "Scope: {{scope}}"
msgstr "Geltungsbereich: {{scope}}"
#. From: konnect##login##headline
#: konnect##login##headline
msgid "Sign in"
msgstr "Anmelden"
#. From: konnect##goodbye##signoutButton##label
#. konnect##welcome##signoutButton##label
#: konnect##goodbye##signoutButton##label
#: konnect##welcome##signoutButton##label
msgid "Sign out"
msgstr "Abmelden"
#. From: konnect##error##http##unexpectedResponseStatus
#: konnect##error##http##unexpectedResponseStatus
msgid "Unexpected HTTP response: {{status}}. Please check your connection and try again."
msgstr "Unerwartete HTTP-Antwort: {{status}}. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es noch einmal."
#. From: konnect##error##http##unexpectedResponseState
#: konnect##error##http##unexpectedResponseState
msgid "Unexpected response state: {{state}}"
msgstr "Unerwarteter Antwort-Status: {{state}}"
#. From: konnect##chooseaccount##useOther##label
#: konnect##chooseaccount##useOther##label
msgid "Use another account"
msgstr "Anderes Konto"
#. From: konnect##login##usernameField##placeholder##username
#: konnect##login##usernameField##placeholder##username
msgid "Username"
msgstr "Benutzername"
#. From: konnect##welcome##headline
#: konnect##welcome##headline
msgid "Welcome {{displayName}}"
msgstr "Willkommen {{displayName}}"
#. From: konnect##welcome##message
#: konnect##welcome##message
msgid "You are signed in - awesome!"
msgstr "Sie sind angemeldet - super!"
#. From: konnect##goodbye##message##close
#: konnect##goodbye##message##close
msgid "You can close this window now."
msgstr "Sie können dieses Fenster jetzt schließen."
#. From: konnect##goodbye##confirm##subHeader
#: konnect##goodbye##confirm##subHeader
msgid "please confirm sign out"
msgstr "Bitte bestätigen Sie, dass Sie sich abmelden möchten"
#. From: konnect##chooseaccount##subHeader
#: konnect##chooseaccount##subHeader
msgid "to sign in"
msgstr "das angemeldet werden soll"
#. From: konnect##goodbye##subHeader
#: konnect##goodbye##subHeader
msgid "you have been signed out from your account"
msgstr "sie sind jetzt abgemeldet"
#~ msgctxt "konnect.consent.question"
#~ msgid "Allow {clientDisplayName} to do this?"
#~ msgstr "{clientDisplayName} den Zugriff gestatten?"
#~ msgctxt "konnect.error.login.validate.missingUsername"
#~ msgid "Enter an username"
#~ msgstr "Geben Sie einen Benutzername ein"
#~ msgctxt "konnect.welcome.signoutButton.label"
#~ msgid "Sign out"
#~ msgstr "Abmelden"
#~ msgctxt "konnect.chooseaccount.subHeader"
#~ msgid "to sign in to Kopano"
#~ msgstr "um sich bei Kopano anzumelden"
#~ msgctxt "konnect.login.subHeader"
#~ msgid "with your Kopano account"
#~ msgstr "mit Ihrem Kopano Konto"
#~ msgctxt "konnect.consent.message"
#~ msgid "{clientDisplayName} wants to"
#~ msgstr "{clientDisplayName} möchte"
+199
View File
@@ -0,0 +1,199 @@
msgid ""
msgstr ""
"Project-Id-Version: LibreGraph Connect Identifier\n"
"POT-Creation-Date: 2022-03-09T19:33:04.641Z\n"
"PO-Revision-Date: 2022-03-09T10:29:32.094Z\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. From: konnect##consent##message
#: konnect##consent##message
msgid "<0><0><0></0></0></0> wants to"
msgstr ""
#. From: konnect##chooseaccount##useOther##persona##label
#: konnect##chooseaccount##useOther##persona##label
msgid "?"
msgstr ""
#. From: konnect##scopeDescription##aliasBasic
#: konnect##scopeDescription##aliasBasic
msgid "Access your basic account information"
msgstr ""
#. From: konnect##consent##allowButton##label
#: konnect##consent##allowButton##label
msgid "Allow"
msgstr ""
#. From: konnect##consent##question
#: konnect##consent##question
msgid "Allow <1><0></0></1> to do this?"
msgstr ""
#. From: konnect##consent##consequence
#: konnect##consent##consequence
msgid "By clicking Allow, you allow this app to use your information."
msgstr ""
#. From: konnect##consent##cancelButton##label
#: konnect##consent##cancelButton##label
msgid "Cancel"
msgstr ""
#. From: konnect##chooseaccount##headline
#: konnect##chooseaccount##headline
msgid "Choose an account"
msgstr ""
#. From: konnect##consent##tooltip##client
#: konnect##consent##tooltip##client
msgid "Clicking \"Allow\" will redirect you to: {{redirectURI}}"
msgstr ""
#. From: konnect##login##usernameField##placeholder##email
#: konnect##login##usernameField##placeholder##email
msgid "Email"
msgstr ""
#. From: konnect##error##login##validate##missingUsername
#: konnect##error##login##validate##missingUsername
msgid "Enter a valid value."
msgstr ""
#. From: konnect##error##login##validate##missingPassword
#: konnect##error##login##validate##missingPassword
msgid "Enter your password."
msgstr ""
#. From: konnect##loading##error##headline
#: konnect##loading##error##headline
msgid "Failed to connect to server"
msgstr ""
#. From: konnect##goodbye##headline
#: konnect##goodbye##headline
msgid "Goodbye"
msgstr ""
#. From: konnect##goodbye##confirm##headline
#: konnect##goodbye##confirm##headline
msgid "Hello {{displayName}}"
msgstr ""
#. From: konnect##consent##headline
#: konnect##consent##headline
msgid "Hi {{displayName}}"
msgstr ""
#. From: konnect##login##usernameField##placeholder##identity
#: konnect##login##usernameField##placeholder##identity
msgid "Identity"
msgstr ""
#. From: konnect##scopeDescription##offlineAccess
#: konnect##scopeDescription##offlineAccess
msgid "Keep the allowed access persistently and forever"
msgstr ""
#. From: konnect##error##login##failed
#: konnect##error##login##failed
msgid "Logon failed. Please verify your credentials and try again."
msgstr ""
#. From: konnect##error##http##networkError
#: konnect##error##http##networkError
msgid "Network error. Please check your connection and try again."
msgstr ""
#. From: konnect##login##nextButton##label
#: konnect##login##nextButton##label
msgid "Next"
msgstr ""
#. From: konnect##login##passwordField##label
#: konnect##login##passwordField##label
msgid "Password"
msgstr ""
#. From: konnect##goodbye##message##confirm
#: konnect##goodbye##message##confirm
msgid "Press the button below, to sign out from your account now."
msgstr ""
#. From: konnect##login##retryButton##label
#: konnect##login##retryButton##label
msgid "Retry"
msgstr ""
#. From: konnect##scopeDescription##scope
#: konnect##scopeDescription##scope
msgid "Scope: {{scope}}"
msgstr ""
#. From: konnect##login##headline
#: konnect##login##headline
msgid "Sign in"
msgstr ""
#. From: konnect##goodbye##signoutButton##label
#. konnect##welcome##signoutButton##label
#: konnect##goodbye##signoutButton##label
#: konnect##welcome##signoutButton##label
msgid "Sign out"
msgstr ""
#. From: konnect##error##http##unexpectedResponseStatus
#: konnect##error##http##unexpectedResponseStatus
msgid "Unexpected HTTP response: {{status}}. Please check your connection and try again."
msgstr ""
#. From: konnect##error##http##unexpectedResponseState
#: konnect##error##http##unexpectedResponseState
msgid "Unexpected response state: {{state}}"
msgstr ""
#. From: konnect##chooseaccount##useOther##label
#: konnect##chooseaccount##useOther##label
msgid "Use another account"
msgstr ""
#. From: konnect##login##usernameField##placeholder##username
#: konnect##login##usernameField##placeholder##username
msgid "Username"
msgstr ""
#. From: konnect##welcome##headline
#: konnect##welcome##headline
msgid "Welcome {{displayName}}"
msgstr ""
#. From: konnect##welcome##message
#: konnect##welcome##message
msgid "You are signed in - awesome!"
msgstr ""
#. From: konnect##goodbye##message##close
#: konnect##goodbye##message##close
msgid "You can close this window now."
msgstr ""
#. From: konnect##goodbye##confirm##subHeader
#: konnect##goodbye##confirm##subHeader
msgid "please confirm sign out"
msgstr ""
#. From: konnect##chooseaccount##subHeader
#: konnect##chooseaccount##subHeader
msgid "to sign in"
msgstr ""
#. From: konnect##goodbye##subHeader
#: konnect##goodbye##subHeader
msgid "you have been signed out from your account"
msgstr ""
+224
View File
@@ -0,0 +1,224 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-03-09T19:33:04.641Z\n"
"PO-Revision-Date: 2022-03-09 18:42+0100\n"
"Last-Translator: Simon Eisenmann <simon@longsleep.org>\n"
"Language-Team: French <http://translate.kopano.io/projects/kopano/konnect/fr/>\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Poedit 2.3\n"
#. From: konnect##consent##message
#: konnect##consent##message
msgid "<0><0><0></0></0></0> wants to"
msgstr "<0><0><0></0></0></0> souhaite"
#. From: konnect##chooseaccount##useOther##persona##label
#: konnect##chooseaccount##useOther##persona##label
msgid "?"
msgstr "?"
#. From: konnect##scopeDescription##aliasBasic
#: konnect##scopeDescription##aliasBasic
msgid "Access your basic account information"
msgstr "Consulter les informations de base de votre compte"
#. From: konnect##consent##allowButton##label
#: konnect##consent##allowButton##label
msgid "Allow"
msgstr "Autoriser"
#. From: konnect##consent##question
#: konnect##consent##question
msgid "Allow <1><0></0></1> to do this?"
msgstr "Autoriser <1><0></0></1> à faire cela?"
#. From: konnect##consent##consequence
#: konnect##consent##consequence
msgid "By clicking Allow, you allow this app to use your information."
msgstr "En cliquant, vous autoriser l'app à accéder à vos informations."
#. From: konnect##consent##cancelButton##label
#: konnect##consent##cancelButton##label
msgid "Cancel"
msgstr "Annuler"
#. From: konnect##chooseaccount##headline
#: konnect##chooseaccount##headline
msgid "Choose an account"
msgstr "Choisir un compte"
#. From: konnect##consent##tooltip##client
#: konnect##consent##tooltip##client
msgid "Clicking \"Allow\" will redirect you to: {{redirectURI}}"
msgstr "En cliquant \"Autoriser\" vous serez redirigé vers: {{redirectURI}}"
#. From: konnect##login##usernameField##placeholder##email
#: konnect##login##usernameField##placeholder##email
msgid "Email"
msgstr ""
#. From: konnect##error##login##validate##missingUsername
#: konnect##error##login##validate##missingUsername
msgid "Enter a valid value."
msgstr ""
#. From: konnect##error##login##validate##missingPassword
#: konnect##error##login##validate##missingPassword
msgid "Enter your password."
msgstr "Saisir un mot de passe."
#. From: konnect##loading##error##headline
#: konnect##loading##error##headline
msgid "Failed to connect to server"
msgstr "La connexion au serveur a échoué"
#. From: konnect##goodbye##headline
#: konnect##goodbye##headline
msgid "Goodbye"
msgstr "Au revoir"
#. From: konnect##goodbye##confirm##headline
#: konnect##goodbye##confirm##headline
msgid "Hello {{displayName}}"
msgstr "Bonjour {{displayName}}"
#. From: konnect##consent##headline
#: konnect##consent##headline
msgid "Hi {{displayName}}"
msgstr "Bonjour {{displayName}}"
#. From: konnect##login##usernameField##placeholder##identity
#: konnect##login##usernameField##placeholder##identity
msgid "Identity"
msgstr ""
#. From: konnect##scopeDescription##offlineAccess
#: konnect##scopeDescription##offlineAccess
msgid "Keep the allowed access persistently and forever"
msgstr "Conserver les autorisations d'accès à l'avenir"
#. From: konnect##error##login##failed
#: konnect##error##login##failed
msgid "Logon failed. Please verify your credentials and try again."
msgstr "Echec de connexion. Vérifier vos identifiants et essayer à nouveau."
#. From: konnect##error##http##networkError
#: konnect##error##http##networkError
msgid "Network error. Please check your connection and try again."
msgstr "Erreur réseau. Vérifier votre connexion, et réessayer."
#. From: konnect##login##nextButton##label
#: konnect##login##nextButton##label
msgid "Next"
msgstr "Suivant"
#. From: konnect##login##passwordField##label
#: konnect##login##passwordField##label
msgid "Password"
msgstr "Mot de passe"
#. From: konnect##goodbye##message##confirm
#: konnect##goodbye##message##confirm
msgid "Press the button below, to sign out from your account now."
msgstr "Cliquer le bouton ci-dessous, pour quitter."
#. From: konnect##login##retryButton##label
#: konnect##login##retryButton##label
msgid "Retry"
msgstr "Réessayer"
#. From: konnect##scopeDescription##scope
#: konnect##scopeDescription##scope
msgid "Scope: {{scope}}"
msgstr "Portée : {{scope}}"
#. From: konnect##login##headline
#: konnect##login##headline
msgid "Sign in"
msgstr "Identification"
#. From: konnect##goodbye##signoutButton##label
#. konnect##welcome##signoutButton##label
#: konnect##goodbye##signoutButton##label
#: konnect##welcome##signoutButton##label
msgid "Sign out"
msgstr "Quitter"
#. From: konnect##error##http##unexpectedResponseStatus
#: konnect##error##http##unexpectedResponseStatus
msgid "Unexpected HTTP response: {{status}}. Please check your connection and try again."
msgstr "Erreur HTTP inattendue : {{status}}. Vérifier votre connexion et réessayer."
#. From: konnect##error##http##unexpectedResponseState
#: konnect##error##http##unexpectedResponseState
msgid "Unexpected response state: {{state}}"
msgstr "Erreur d'état inattendue : {{state}}"
#. From: konnect##chooseaccount##useOther##label
#: konnect##chooseaccount##useOther##label
msgid "Use another account"
msgstr "Utiliser un autre compte"
#. From: konnect##login##usernameField##placeholder##username
#: konnect##login##usernameField##placeholder##username
msgid "Username"
msgstr "Utilisateur"
#. From: konnect##welcome##headline
#: konnect##welcome##headline
msgid "Welcome {{displayName}}"
msgstr "Bienvenue {{displayName}}"
#. From: konnect##welcome##message
#: konnect##welcome##message
msgid "You are signed in - awesome!"
msgstr "Magnifique - Vous êtes connecté!"
#. From: konnect##goodbye##message##close
#: konnect##goodbye##message##close
msgid "You can close this window now."
msgstr "Vous pouvez fermer cette fenêtre à présent."
#. From: konnect##goodbye##confirm##subHeader
#: konnect##goodbye##confirm##subHeader
msgid "please confirm sign out"
msgstr "confirmer votre déconnexion"
#. From: konnect##chooseaccount##subHeader
#: konnect##chooseaccount##subHeader
msgid "to sign in"
msgstr "identification"
#. From: konnect##goodbye##subHeader
#: konnect##goodbye##subHeader
msgid "you have been signed out from your account"
msgstr "vous avez été déconnecté"
#~ msgctxt "konnect.consent.question"
#~ msgid "Allow {clientDisplayName} to do this?"
#~ msgstr "Autoriser {clientDisplayName} à faire cela ?"
#~ msgctxt "konnect.error.login.validate.missingUsername"
#~ msgid "Enter an username"
#~ msgstr "Saisir un identifiant"
#~ msgctxt "konnect.welcome.signoutButton.label"
#~ msgid "Sign out"
#~ msgstr "Quitter"
#~ msgctxt "konnect.chooseaccount.subHeader"
#~ msgid "to sign in to Kopano"
#~ msgstr "pour vous authentifier dans Kopano"
#~ msgctxt "konnect.login.subHeader"
#~ msgid "with your Kopano account"
#~ msgstr "avec vos identifiants Kopano"
#~ msgctxt "konnect.consent.message"
#~ msgid "{clientDisplayName} wants to"
#~ msgstr "{clientDisplayName} souhaite"
+200
View File
@@ -0,0 +1,200 @@
msgid ""
msgstr ""
"Project-Id-Version: LibreGraph Connect Identifier\n"
"POT-Creation-Date: 2022-03-09T19:33:04.641Z\n"
"PO-Revision-Date: 2022-03-09 11:22+0100\n"
"Last-Translator: Simon Eisenmann <simon@longsleep.org>\n"
"Language-Team: \n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.3\n"
#. From: konnect##consent##message
#: konnect##consent##message
msgid "<0><0><0></0></0></0> wants to"
msgstr ""
#. From: konnect##chooseaccount##useOther##persona##label
#: konnect##chooseaccount##useOther##persona##label
msgid "?"
msgstr ""
#. From: konnect##scopeDescription##aliasBasic
#: konnect##scopeDescription##aliasBasic
msgid "Access your basic account information"
msgstr ""
#. From: konnect##consent##allowButton##label
#: konnect##consent##allowButton##label
msgid "Allow"
msgstr ""
#. From: konnect##consent##question
#: konnect##consent##question
msgid "Allow <1><0></0></1> to do this?"
msgstr ""
#. From: konnect##consent##consequence
#: konnect##consent##consequence
msgid "By clicking Allow, you allow this app to use your information."
msgstr ""
#. From: konnect##consent##cancelButton##label
#: konnect##consent##cancelButton##label
msgid "Cancel"
msgstr ""
#. From: konnect##chooseaccount##headline
#: konnect##chooseaccount##headline
msgid "Choose an account"
msgstr ""
#. From: konnect##consent##tooltip##client
#: konnect##consent##tooltip##client
msgid "Clicking \"Allow\" will redirect you to: {{redirectURI}}"
msgstr ""
#. From: konnect##login##usernameField##placeholder##email
#: konnect##login##usernameField##placeholder##email
msgid "Email"
msgstr ""
#. From: konnect##error##login##validate##missingUsername
#: konnect##error##login##validate##missingUsername
msgid "Enter a valid value."
msgstr ""
#. From: konnect##error##login##validate##missingPassword
#: konnect##error##login##validate##missingPassword
msgid "Enter your password."
msgstr ""
#. From: konnect##loading##error##headline
#: konnect##loading##error##headline
msgid "Failed to connect to server"
msgstr ""
#. From: konnect##goodbye##headline
#: konnect##goodbye##headline
msgid "Goodbye"
msgstr ""
#. From: konnect##goodbye##confirm##headline
#: konnect##goodbye##confirm##headline
msgid "Hello {{displayName}}"
msgstr ""
#. From: konnect##consent##headline
#: konnect##consent##headline
msgid "Hi {{displayName}}"
msgstr ""
#. From: konnect##login##usernameField##placeholder##identity
#: konnect##login##usernameField##placeholder##identity
msgid "Identity"
msgstr ""
#. From: konnect##scopeDescription##offlineAccess
#: konnect##scopeDescription##offlineAccess
msgid "Keep the allowed access persistently and forever"
msgstr ""
#. From: konnect##error##login##failed
#: konnect##error##login##failed
msgid "Logon failed. Please verify your credentials and try again."
msgstr ""
#. From: konnect##error##http##networkError
#: konnect##error##http##networkError
msgid "Network error. Please check your connection and try again."
msgstr ""
#. From: konnect##login##nextButton##label
#: konnect##login##nextButton##label
msgid "Next"
msgstr ""
#. From: konnect##login##passwordField##label
#: konnect##login##passwordField##label
msgid "Password"
msgstr ""
#. From: konnect##goodbye##message##confirm
#: konnect##goodbye##message##confirm
msgid "Press the button below, to sign out from your account now."
msgstr ""
#. From: konnect##login##retryButton##label
#: konnect##login##retryButton##label
msgid "Retry"
msgstr ""
#. From: konnect##scopeDescription##scope
#: konnect##scopeDescription##scope
msgid "Scope: {{scope}}"
msgstr ""
#. From: konnect##login##headline
#: konnect##login##headline
msgid "Sign in"
msgstr ""
#. From: konnect##goodbye##signoutButton##label
#. konnect##welcome##signoutButton##label
#: konnect##goodbye##signoutButton##label
#: konnect##welcome##signoutButton##label
msgid "Sign out"
msgstr ""
#. From: konnect##error##http##unexpectedResponseStatus
#: konnect##error##http##unexpectedResponseStatus
msgid "Unexpected HTTP response: {{status}}. Please check your connection and try again."
msgstr ""
#. From: konnect##error##http##unexpectedResponseState
#: konnect##error##http##unexpectedResponseState
msgid "Unexpected response state: {{state}}"
msgstr ""
#. From: konnect##chooseaccount##useOther##label
#: konnect##chooseaccount##useOther##label
msgid "Use another account"
msgstr ""
#. From: konnect##login##usernameField##placeholder##username
#: konnect##login##usernameField##placeholder##username
msgid "Username"
msgstr ""
#. From: konnect##welcome##headline
#: konnect##welcome##headline
msgid "Welcome {{displayName}}"
msgstr ""
#. From: konnect##welcome##message
#: konnect##welcome##message
msgid "You are signed in - awesome!"
msgstr ""
#. From: konnect##goodbye##message##close
#: konnect##goodbye##message##close
msgid "You can close this window now."
msgstr ""
#. From: konnect##goodbye##confirm##subHeader
#: konnect##goodbye##confirm##subHeader
msgid "please confirm sign out"
msgstr ""
#. From: konnect##chooseaccount##subHeader
#: konnect##chooseaccount##subHeader
msgid "to sign in"
msgstr ""
#. From: konnect##goodbye##subHeader
#: konnect##goodbye##subHeader
msgid "you have been signed out from your account"
msgstr ""
+224
View File
@@ -0,0 +1,224 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2022-03-09T19:33:04.641Z\n"
"PO-Revision-Date: 2022-03-09 18:45+0100\n"
"Last-Translator: Simon Eisenmann <simon@longsleep.org>\n"
"Language-Team: Dutch <http://translate.kopano.io/projects/kopano/konnect/nl/>\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Poedit 2.3\n"
#. From: konnect##consent##message
#: konnect##consent##message
msgid "<0><0><0></0></0></0> wants to"
msgstr "<0><0><0></0></0></0> wil"
#. From: konnect##chooseaccount##useOther##persona##label
#: konnect##chooseaccount##useOther##persona##label
msgid "?"
msgstr "?"
#. From: konnect##scopeDescription##aliasBasic
#: konnect##scopeDescription##aliasBasic
msgid "Access your basic account information"
msgstr "Basis accountgegevens weergeven"
#. From: konnect##consent##allowButton##label
#: konnect##consent##allowButton##label
msgid "Allow"
msgstr "Toestaan"
#. From: konnect##consent##question
#: konnect##consent##question
msgid "Allow <1><0></0></1> to do this?"
msgstr "<1><0></0></1> toestaan dit te doen?"
#. From: konnect##consent##consequence
#: konnect##consent##consequence
msgid "By clicking Allow, you allow this app to use your information."
msgstr "Door op Toestaan te klikken, krijgt deze app toestemming je informatie te gebruiken."
#. From: konnect##consent##cancelButton##label
#: konnect##consent##cancelButton##label
msgid "Cancel"
msgstr "Annuleren"
#. From: konnect##chooseaccount##headline
#: konnect##chooseaccount##headline
msgid "Choose an account"
msgstr "Account kiezen"
#. From: konnect##consent##tooltip##client
#: konnect##consent##tooltip##client
msgid "Clicking \"Allow\" will redirect you to: {{redirectURI}}"
msgstr "Door op \"Toestaan\" te klikken word je doorverwezen naar: {{redirectURI}}"
#. From: konnect##login##usernameField##placeholder##email
#: konnect##login##usernameField##placeholder##email
msgid "Email"
msgstr ""
#. From: konnect##error##login##validate##missingUsername
#: konnect##error##login##validate##missingUsername
msgid "Enter a valid value."
msgstr ""
#. From: konnect##error##login##validate##missingPassword
#: konnect##error##login##validate##missingPassword
msgid "Enter your password."
msgstr "Voer een wachtwoord in."
#. From: konnect##loading##error##headline
#: konnect##loading##error##headline
msgid "Failed to connect to server"
msgstr "Kon niet met server verbinden"
#. From: konnect##goodbye##headline
#: konnect##goodbye##headline
msgid "Goodbye"
msgstr "Tot ziens"
#. From: konnect##goodbye##confirm##headline
#: konnect##goodbye##confirm##headline
msgid "Hello {{displayName}}"
msgstr "Hallo {{displayName}}"
#. From: konnect##consent##headline
#: konnect##consent##headline
msgid "Hi {{displayName}}"
msgstr "Hoi {{displayName}}"
#. From: konnect##login##usernameField##placeholder##identity
#: konnect##login##usernameField##placeholder##identity
msgid "Identity"
msgstr ""
#. From: konnect##scopeDescription##offlineAccess
#: konnect##scopeDescription##offlineAccess
msgid "Keep the allowed access persistently and forever"
msgstr "De toestemming voor altijd onthouden"
#. From: konnect##error##login##failed
#: konnect##error##login##failed
msgid "Logon failed. Please verify your credentials and try again."
msgstr "Inloggen mislukt. Controleer logingegevens en probeer opnieuw."
#. From: konnect##error##http##networkError
#: konnect##error##http##networkError
msgid "Network error. Please check your connection and try again."
msgstr "Netwerk probleem. Controleer je verbinding en probeer opnieuw."
#. From: konnect##login##nextButton##label
#: konnect##login##nextButton##label
msgid "Next"
msgstr "Volgende"
#. From: konnect##login##passwordField##label
#: konnect##login##passwordField##label
msgid "Password"
msgstr "Wachtwoord"
#. From: konnect##goodbye##message##confirm
#: konnect##goodbye##message##confirm
msgid "Press the button below, to sign out from your account now."
msgstr "Klik op onderstaande knop om af te melden van je account."
#. From: konnect##login##retryButton##label
#: konnect##login##retryButton##label
msgid "Retry"
msgstr "Opnieuw"
#. From: konnect##scopeDescription##scope
#: konnect##scopeDescription##scope
msgid "Scope: {{scope}}"
msgstr "Scope: {{scope}}"
#. From: konnect##login##headline
#: konnect##login##headline
msgid "Sign in"
msgstr "Aanmelden"
#. From: konnect##goodbye##signoutButton##label
#. konnect##welcome##signoutButton##label
#: konnect##goodbye##signoutButton##label
#: konnect##welcome##signoutButton##label
msgid "Sign out"
msgstr "Afmelden"
#. From: konnect##error##http##unexpectedResponseStatus
#: konnect##error##http##unexpectedResponseStatus
msgid "Unexpected HTTP response: {{status}}. Please check your connection and try again."
msgstr "Onverwachte HTTP respons: {{status}}. Controleer je verbinding en probeer opnieuw."
#. From: konnect##error##http##unexpectedResponseState
#: konnect##error##http##unexpectedResponseState
msgid "Unexpected response state: {{state}}"
msgstr "Onverwachte respons status: {{state}}"
#. From: konnect##chooseaccount##useOther##label
#: konnect##chooseaccount##useOther##label
msgid "Use another account"
msgstr "Gebruik een ander account"
#. From: konnect##login##usernameField##placeholder##username
#: konnect##login##usernameField##placeholder##username
msgid "Username"
msgstr "Gebruikersnaam"
#. From: konnect##welcome##headline
#: konnect##welcome##headline
msgid "Welcome {{displayName}}"
msgstr "Welkom {{displayName}}"
#. From: konnect##welcome##message
#: konnect##welcome##message
msgid "You are signed in - awesome!"
msgstr "Je bent aangemeld - fantastisch!"
#. From: konnect##goodbye##message##close
#: konnect##goodbye##message##close
msgid "You can close this window now."
msgstr "Dit venster kan nu worden gesloten."
#. From: konnect##goodbye##confirm##subHeader
#: konnect##goodbye##confirm##subHeader
msgid "please confirm sign out"
msgstr "bevestig afmelden"
#. From: konnect##chooseaccount##subHeader
#: konnect##chooseaccount##subHeader
msgid "to sign in"
msgstr "aanmelden"
#. From: konnect##goodbye##subHeader
#: konnect##goodbye##subHeader
msgid "you have been signed out from your account"
msgstr "je bent afgemeld van je account"
#~ msgctxt "konnect.consent.question"
#~ msgid "Allow {clientDisplayName} to do this?"
#~ msgstr "{clientDisplayName} toestaan dit te doen?"
#~ msgctxt "konnect.error.login.validate.missingUsername"
#~ msgid "Enter an username"
#~ msgstr "Voer een gebruikersnaam in"
#~ msgctxt "konnect.welcome.signoutButton.label"
#~ msgid "Sign out"
#~ msgstr "Afmelden"
#~ msgctxt "konnect.chooseaccount.subHeader"
#~ msgid "to sign in to Kopano"
#~ msgstr "om aan te melden bij Kopano"
#~ msgctxt "konnect.login.subHeader"
#~ msgid "with your Kopano account"
#~ msgstr "met je Kopano account"
#~ msgctxt "konnect.consent.message"
#~ msgid "{clientDisplayName} wants to"
#~ msgstr "{clientDisplayName} wil"
+194
View File
@@ -0,0 +1,194 @@
msgid ""
msgstr ""
"Project-Id-Version: LibreGraph Connect Identifier\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2022-03-09T19:33:04.641Z\n"
"MIME-Version: 1.0\n"
#. From: konnect##loading##error##headline
#: konnect##loading##error##headline
msgid "Failed to connect to server"
msgstr ""
#. From: konnect##login##retryButton##label
#: konnect##login##retryButton##label
msgid "Retry"
msgstr ""
#. From: konnect##login##usernameField##placeholder##email
#: konnect##login##usernameField##placeholder##email
msgid "Email"
msgstr ""
#. From: konnect##login##usernameField##placeholder##identity
#: konnect##login##usernameField##placeholder##identity
msgid "Identity"
msgstr ""
#. From: konnect##login##usernameField##placeholder##username
#: konnect##login##usernameField##placeholder##username
msgid "Username"
msgstr ""
#. From: konnect##login##headline
#: konnect##login##headline
msgid "Sign in"
msgstr ""
#. From: konnect##login##passwordField##label
#: konnect##login##passwordField##label
msgid "Password"
msgstr ""
#. From: konnect##login##nextButton##label
#: konnect##login##nextButton##label
msgid "Next"
msgstr ""
#. From: konnect##scopeDescription##aliasBasic
#: konnect##scopeDescription##aliasBasic
msgid "Access your basic account information"
msgstr ""
#. From: konnect##scopeDescription##offlineAccess
#: konnect##scopeDescription##offlineAccess
msgid "Keep the allowed access persistently and forever"
msgstr ""
#. From: konnect##scopeDescription##scope
#: konnect##scopeDescription##scope
msgid "Scope: {{scope}}"
msgstr ""
#. From: konnect##goodbye##headline
#: konnect##goodbye##headline
msgid "Goodbye"
msgstr ""
#. From: konnect##goodbye##subHeader
#: konnect##goodbye##subHeader
msgid "you have been signed out from your account"
msgstr ""
#. From: konnect##goodbye##message##close
#: konnect##goodbye##message##close
msgid "You can close this window now."
msgstr ""
#. From: konnect##goodbye##message##confirm
#: konnect##goodbye##message##confirm
msgid "Press the button below, to sign out from your account now."
msgstr ""
#. From: konnect##goodbye##confirm##headline
#: konnect##goodbye##confirm##headline
msgid "Hello {{displayName}}"
msgstr ""
#. From: konnect##goodbye##confirm##subHeader
#: konnect##goodbye##confirm##subHeader
msgid "please confirm sign out"
msgstr ""
#. From: konnect##goodbye##signoutButton##label
#. konnect##welcome##signoutButton##label
#: konnect##goodbye##signoutButton##label
#: konnect##welcome##signoutButton##label
msgid "Sign out"
msgstr ""
#. From: konnect##chooseaccount##headline
#: konnect##chooseaccount##headline
msgid "Choose an account"
msgstr ""
#. From: konnect##chooseaccount##subHeader
#: konnect##chooseaccount##subHeader
msgid "to sign in"
msgstr ""
#. From: konnect##chooseaccount##useOther##persona##label
#: konnect##chooseaccount##useOther##persona##label
msgid "?"
msgstr ""
#. From: konnect##chooseaccount##useOther##label
#: konnect##chooseaccount##useOther##label
msgid "Use another account"
msgstr ""
#. From: konnect##consent##headline
#: konnect##consent##headline
msgid "Hi {{displayName}}"
msgstr ""
#. From: konnect##consent##message
#: konnect##consent##message
msgid "<0><0><0></0></0></0> wants to"
msgstr ""
#. From: konnect##consent##tooltip##client
#: konnect##consent##tooltip##client
msgid "Clicking \"Allow\" will redirect you to: {{redirectURI}}"
msgstr ""
#. From: konnect##consent##question
#: konnect##consent##question
msgid "Allow <1><0></0></1> to do this?"
msgstr ""
#. From: konnect##consent##consequence
#: konnect##consent##consequence
msgid "By clicking Allow, you allow this app to use your information."
msgstr ""
#. From: konnect##consent##cancelButton##label
#: konnect##consent##cancelButton##label
msgid "Cancel"
msgstr ""
#. From: konnect##consent##allowButton##label
#: konnect##consent##allowButton##label
msgid "Allow"
msgstr ""
#. From: konnect##welcome##headline
#: konnect##welcome##headline
msgid "Welcome {{displayName}}"
msgstr ""
#. From: konnect##welcome##message
#: konnect##welcome##message
msgid "You are signed in - awesome!"
msgstr ""
#. From: konnect##error##login##validate##missingUsername
#: konnect##error##login##validate##missingUsername
msgid "Enter a valid value."
msgstr ""
#. From: konnect##error##login##validate##missingPassword
#: konnect##error##login##validate##missingPassword
msgid "Enter your password."
msgstr ""
#. From: konnect##error##login##failed
#: konnect##error##login##failed
msgid "Logon failed. Please verify your credentials and try again."
msgstr ""
#. From: konnect##error##http##networkError
#: konnect##error##http##networkError
msgid "Network error. Please check your connection and try again."
msgstr ""
#. From: konnect##error##http##unexpectedResponseStatus
#: konnect##error##http##unexpectedResponseStatus
msgid "Unexpected HTTP response: {{status}}. Please check your connection and try again."
msgstr ""
#. From: konnect##error##http##unexpectedResponseState
#: konnect##error##http##unexpectedResponseState
msgid "Unexpected response state: {{state}}"
msgstr ""
+198
View File
@@ -0,0 +1,198 @@
msgid ""
msgstr ""
"Project-Id-Version: LibreGraph Connect Identifier\n"
"POT-Creation-Date: 2022-03-09T19:33:04.641Z\n"
"PO-Revision-Date: 2022-03-09T10:29:32.094Z\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: zh-CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#. From: konnect##consent##message
#: konnect##consent##message
msgid "<0><0><0></0></0></0> wants to"
msgstr ""
#. From: konnect##chooseaccount##useOther##persona##label
#: konnect##chooseaccount##useOther##persona##label
msgid "?"
msgstr ""
#. From: konnect##scopeDescription##aliasBasic
#: konnect##scopeDescription##aliasBasic
msgid "Access your basic account information"
msgstr ""
#. From: konnect##consent##allowButton##label
#: konnect##consent##allowButton##label
msgid "Allow"
msgstr ""
#. From: konnect##consent##question
#: konnect##consent##question
msgid "Allow <1><0></0></1> to do this?"
msgstr ""
#. From: konnect##consent##consequence
#: konnect##consent##consequence
msgid "By clicking Allow, you allow this app to use your information."
msgstr ""
#. From: konnect##consent##cancelButton##label
#: konnect##consent##cancelButton##label
msgid "Cancel"
msgstr ""
#. From: konnect##chooseaccount##headline
#: konnect##chooseaccount##headline
msgid "Choose an account"
msgstr ""
#. From: konnect##consent##tooltip##client
#: konnect##consent##tooltip##client
msgid "Clicking \"Allow\" will redirect you to: {{redirectURI}}"
msgstr ""
#. From: konnect##login##usernameField##placeholder##email
#: konnect##login##usernameField##placeholder##email
msgid "Email"
msgstr ""
#. From: konnect##error##login##validate##missingUsername
#: konnect##error##login##validate##missingUsername
msgid "Enter a valid value."
msgstr ""
#. From: konnect##error##login##validate##missingPassword
#: konnect##error##login##validate##missingPassword
msgid "Enter your password."
msgstr ""
#. From: konnect##loading##error##headline
#: konnect##loading##error##headline
msgid "Failed to connect to server"
msgstr ""
#. From: konnect##goodbye##headline
#: konnect##goodbye##headline
msgid "Goodbye"
msgstr ""
#. From: konnect##goodbye##confirm##headline
#: konnect##goodbye##confirm##headline
msgid "Hello {{displayName}}"
msgstr ""
#. From: konnect##consent##headline
#: konnect##consent##headline
msgid "Hi {{displayName}}"
msgstr ""
#. From: konnect##login##usernameField##placeholder##identity
#: konnect##login##usernameField##placeholder##identity
msgid "Identity"
msgstr ""
#. From: konnect##scopeDescription##offlineAccess
#: konnect##scopeDescription##offlineAccess
msgid "Keep the allowed access persistently and forever"
msgstr ""
#. From: konnect##error##login##failed
#: konnect##error##login##failed
msgid "Logon failed. Please verify your credentials and try again."
msgstr ""
#. From: konnect##error##http##networkError
#: konnect##error##http##networkError
msgid "Network error. Please check your connection and try again."
msgstr ""
#. From: konnect##login##nextButton##label
#: konnect##login##nextButton##label
msgid "Next"
msgstr ""
#. From: konnect##login##passwordField##label
#: konnect##login##passwordField##label
msgid "Password"
msgstr ""
#. From: konnect##goodbye##message##confirm
#: konnect##goodbye##message##confirm
msgid "Press the button below, to sign out from your account now."
msgstr ""
#. From: konnect##login##retryButton##label
#: konnect##login##retryButton##label
msgid "Retry"
msgstr ""
#. From: konnect##scopeDescription##scope
#: konnect##scopeDescription##scope
msgid "Scope: {{scope}}"
msgstr ""
#. From: konnect##login##headline
#: konnect##login##headline
msgid "Sign in"
msgstr ""
#. From: konnect##goodbye##signoutButton##label
#. konnect##welcome##signoutButton##label
#: konnect##goodbye##signoutButton##label
#: konnect##welcome##signoutButton##label
msgid "Sign out"
msgstr ""
#. From: konnect##error##http##unexpectedResponseStatus
#: konnect##error##http##unexpectedResponseStatus
msgid "Unexpected HTTP response: {{status}}. Please check your connection and try again."
msgstr ""
#. From: konnect##error##http##unexpectedResponseState
#: konnect##error##http##unexpectedResponseState
msgid "Unexpected response state: {{state}}"
msgstr ""
#. From: konnect##chooseaccount##useOther##label
#: konnect##chooseaccount##useOther##label
msgid "Use another account"
msgstr ""
#. From: konnect##login##usernameField##placeholder##username
#: konnect##login##usernameField##placeholder##username
msgid "Username"
msgstr ""
#. From: konnect##welcome##headline
#: konnect##welcome##headline
msgid "Welcome {{displayName}}"
msgstr ""
#. From: konnect##welcome##message
#: konnect##welcome##message
msgid "You are signed in - awesome!"
msgstr ""
#. From: konnect##goodbye##message##close
#: konnect##goodbye##message##close
msgid "You can close this window now."
msgstr ""
#. From: konnect##goodbye##confirm##subHeader
#: konnect##goodbye##confirm##subHeader
msgid "please confirm sign out"
msgstr ""
#. From: konnect##chooseaccount##subHeader
#: konnect##chooseaccount##subHeader
msgid "to sign in"
msgstr ""
#. From: konnect##goodbye##subHeader
#: konnect##goodbye##subHeader
msgid "you have been signed out from your account"
msgstr ""
+9
View File
@@ -0,0 +1,9 @@
package idp
import (
"embed"
)
//go:generate make generate
//go:embed assets/*
var Assets embed.FS
@@ -0,0 +1,14 @@
{
"i18next-conv@15.1.2": {
"licenses": "MIT",
"licenseFile": "LICENSE.md",
"licenseStart": "# MIT License",
"licenseEnd": "========="
},
"p-from-callback@2.0.0": {
"licenses": "MIT",
"licenseFile": "LICENSE.md",
"licenseStart": "# MIT License",
"licenseEnd": "========="
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"name": "identifier",
"version": "1.0.0",
"private": true,
"homepage": ".",
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"build": "node --openssl-legacy-provider scripts/build.js && rm -f build/service-worker.js",
"licenses": "NODE_PATH=./node_modules node ../scripts/js-license-ranger.js",
"licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;ODC-By-1.0;BlueOak-1.0.0;OFL-1.1' --excludePackages 'identifier;unicoderegexp' --clarificationsFile license-checker-clarifications.json",
"licenses:csv": "license-checker-rseidelsohn --relativeLicensePath --csv --out ../../third-party-licenses/node/idp/third-party-licenses.csv",
"licenses:save": "license-checker-rseidelsohn --relativeLicensePath --out /dev/null --files ../../third-party-licenses/node/idp/third-party-licenses"
},
"babel": {
"presets": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@material-ui/core": "^4.12.4",
"@types/node": "^25.7.0",
"@types/react": "^17.0.91",
"@types/react-dom": "^17.0.26",
"@types/react-redux": "^7.1.34",
"@types/redux-logger": "^3.0.13",
"axios": "^1.16.0",
"i18next": "^26.1.0",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1",
"query-string": "^9.3.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^15.7.4",
"react-redux": "^8.1.3",
"react-router": "^5.3.4",
"react-router-dom": "5.2.1",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2"
},
"devDependencies": {
"@babel/core": "7.26.10",
"babel-loader": "10.0.0",
"babel-plugin-named-asset-import": "^0.3.8",
"babel-preset-react-app": "^10.1.0",
"case-sensitive-paths-webpack-plugin": "2.4.0",
"cldr": "^7.9.0",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^8.0.0",
"dotenv": "17.4.2",
"dotenv-expand": "^13.0.0",
"html-webpack-plugin": "^5.6.7",
"i18next-conv": "^15.1.2",
"i18next-parser": "^9.4.0",
"license-checker-rseidelsohn": "4.4.2",
"mini-css-extract-plugin": "2.9.2",
"pnp-webpack-plugin": "1.7.0",
"postcss-flexbugs-fixes": "5.0.2",
"postcss-loader": "4.3.0",
"postcss-normalize": "13.0.1",
"postcss-preset-env": "11.2.0",
"react-dev-utils": "^12.0.1",
"resolve": "^1.22.12",
"resolve-url-loader": "^5.0.0",
"sass-loader": "^16.0.8",
"source-map-explorer": "^2.5.3",
"typescript": "^5.9.3",
"url-loader": "4.1.1",
"webpack": "5.105.2",
"webpack-manifest-plugin": "5.0.0",
"workbox-webpack-plugin": "7.4.0"
},
"pnpm": {
"overrides": {
"fast-uri": ">=3.1.2",
"serialize-javascript@<7.0.3": ">=7.0.3",
"@xmldom/xmldom": ">=0.8.13"
}
},
"packageManager": "pnpm@9.15.4"
}
+58
View File
@@ -0,0 +1,58 @@
package assets
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/x/io/fsx"
"github.com/qsfera/server/services/idp"
"github.com/qsfera/server/services/idp/pkg/config"
)
// New returns a new http filesystem to serve assets.
func New(opts ...Option) http.FileSystem {
options := newOptions(opts...)
var assetFS fsx.FS = fsx.NewBasePathFs(fsx.FromIOFS(idp.Assets), "assets")
// only use a fsx.NewFallbackFS and fsx.OsFs if a path is set, use the embedded fs only otherwise
if options.Config.Asset.Path != "" {
assetFS = fsx.NewFallbackFS(fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.Path), assetFS)
}
return http.FS(assetFS.IOFS())
}
// Option defines a single option function.
type Option func(o *Options)
// Options define the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,136 @@
/*
* Copyright 2021 Kopano and its licensors
*
* 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.
*
*/
package bootstrap
import (
"fmt"
"os"
"github.com/libregraph/lico/bootstrap"
"github.com/libregraph/lico/identifier"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/managers"
cs3 "github.com/qsfera/server/services/idp/pkg/backends/cs3/identifier"
)
// Identity managers.
const (
identityManagerName = "cs3"
)
// Register adds the CS3 identity manager to the lico bootstrap
func Register() error {
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
}
// MustRegister adds the CS3 identity manager to the lico bootstrap or panics
func MustRegister() {
if err := Register(); err != nil {
panic(err)
}
}
// NewIdentityManager produces a CS3 backed identity manager instance for the idp
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
config := bs.Config()
logger := config.Config.Logger
if config.AuthorizationEndpointURI.String() != "" {
return nil, fmt.Errorf("cs3 backend is incompatible with authorization-endpoint-uri parameter")
}
config.AuthorizationEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/authorize")
if config.EndSessionEndpointURI.String() != "" {
return nil, fmt.Errorf("cs3 backend is incompatible with endsession-endpoint-uri parameter")
}
config.EndSessionEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/endsession")
if config.SignInFormURI.EscapedPath() == "" {
config.SignInFormURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier")
}
if config.SignedOutURI.EscapedPath() == "" {
config.SignedOutURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/goodbye")
}
identifierBackend, identifierErr := cs3.NewCS3Backend(
config.Config,
config.TLSClientConfig,
// FIXME add a map[string]interface{} property to the lico config.Config so backends can pass custom config parameters through the bootstrap process
os.Getenv("CS3_GATEWAY"),
os.Getenv("CS3_MACHINE_AUTH_API_KEY"),
config.Settings.Insecure,
)
if identifierErr != nil {
return nil, fmt.Errorf("failed to create identifier backend: %v", identifierErr)
}
fullAuthorizationEndpointURL := bootstrap.WithSchemeAndHost(config.AuthorizationEndpointURI, config.IssuerIdentifierURI)
fullSignInFormURL := bootstrap.WithSchemeAndHost(config.SignInFormURI, config.IssuerIdentifierURI)
fullSignedOutEndpointURL := bootstrap.WithSchemeAndHost(config.SignedOutURI, config.IssuerIdentifierURI)
activeIdentifier, err := identifier.NewIdentifier(&identifier.Config{
Config: config.Config,
BaseURI: config.IssuerIdentifierURI,
PathPrefix: bs.MakeURIPath(bootstrap.APITypeSignin, ""),
StaticFolder: config.IdentifierClientPath,
ScopesConf: config.IdentifierScopesConf,
WebAppDisabled: config.IdentifierClientDisabled,
LogonCookieName: "__Secure-KKT", // Kopano-Konnect-Token
LogonCookieSameSite: config.CookieSameSite,
ConsentCookieSameSite: config.CookieSameSite,
StateCookieSameSite: config.CookieSameSite,
AuthorizationEndpointURI: fullAuthorizationEndpointURL,
SignedOutEndpointURI: fullSignedOutEndpointURL,
DefaultBannerLogo: config.IdentifierDefaultBannerLogo,
DefaultSignInPageText: config.IdentifierDefaultSignInPageText,
DefaultUsernameHintText: config.IdentifierDefaultUsernameHintText,
UILocales: config.IdentifierUILocales,
Backend: identifierBackend,
})
if err != nil {
return nil, fmt.Errorf("failed to create identifier: %v", err)
}
err = activeIdentifier.SetKey(config.EncryptionSecret)
if err != nil {
return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err)
}
identityManagerConfig := &identity.Config{
SignInFormURI: fullSignInFormURL,
SignedOutURI: fullSignedOutEndpointURL,
Logger: logger,
ScopesSupported: config.Config.AllowedScopes,
}
identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier)
logger.Infoln("using identifier backed identity manager")
return identifierIdentityManager, nil
}
@@ -0,0 +1,230 @@
package cs3
import (
"context"
"crypto/tls"
"fmt"
cs3gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta/scopes"
"github.com/libregraph/lico/identity"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cmap "github.com/orcaman/concurrent-map"
"github.com/sirupsen/logrus"
)
const cs3BackendName = "identifier-cs3"
var cs3SpportedScopes = []string{
"profile",
"email",
lico.ScopeUniqueUserID,
lico.ScopeRawSubject,
}
// CS3Backend holds the data for the CS3 identifier backend
type CS3Backend struct {
supportedScopes []string
logger logrus.FieldLogger
tlsConfig *tls.Config
gatewayAddr string
machineAuthAPIKey string
insecure bool
sessions cmap.ConcurrentMap
}
// NewCS3Backend creates a new CS3 backend identifier backend
func NewCS3Backend(
c *config.Config,
tlsConfig *tls.Config,
gatewayAddr string,
machineAuthAPIKey string,
insecure bool,
) (*CS3Backend, error) {
// Build supported scopes based on default scopes.
supportedScopes := make([]string, len(cs3SpportedScopes))
copy(supportedScopes, cs3SpportedScopes)
b := &CS3Backend{
supportedScopes: supportedScopes,
logger: c.Logger,
tlsConfig: tlsConfig,
gatewayAddr: gatewayAddr,
machineAuthAPIKey: machineAuthAPIKey,
insecure: insecure,
sessions: cmap.New(),
}
b.logger.Infoln("cs3 backend connection set up")
return b, nil
}
// RunWithContext implements the Backend interface.
func (b *CS3Backend) RunWithContext(_ context.Context) error {
return nil
}
// Logon implements the Backend interface, enabling Logon with user name and
// password as provided. Requests are bound to the provided context.
func (b *CS3Backend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
client, err := pool.GetGatewayServiceClient(b.gatewayAddr)
if err != nil {
return false, nil, nil, nil, err
}
res, err := client.Authenticate(ctx, &cs3gateway.AuthenticateRequest{
Type: "basic",
ClientId: username,
ClientSecret: password,
})
if err != nil {
return false, nil, nil, nil, fmt.Errorf("cs3 backend basic authenticate rpc error: %v", err)
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return false, nil, nil, nil, fmt.Errorf("cs3 backend basic authenticate failed with code %s: %s", res.GetStatus().GetCode().String(), res.GetStatus().GetMessage())
}
session := createSession(res.GetUser())
user, err := newCS3User(res.GetUser())
if err != nil {
return false, nil, nil, nil, fmt.Errorf("cs3 backend resolve entry data error: %v", err)
}
// Use the users subject as user id.
userID := user.Subject()
sessionRef := identity.GetSessionRef(b.Name(), audience, userID)
b.sessions.Set(*sessionRef, session)
b.logger.WithFields(logrus.Fields{
"session": session,
"ref": *sessionRef,
"username": user.Username(),
"id": userID,
}).Debugln("cs3 backend logon")
return true, &userID, sessionRef, user, nil
}
// GetUser implements the Backend interface, providing user meta data retrieval
// for the user specified by the userID. Requests are bound to the provided
// context.
func (b *CS3Backend) GetUser(ctx context.Context, userEntryID string, sessionRef *string, _ map[string]bool) (backends.UserFromBackend, error) {
var session *cs3Session
if s, ok := b.sessions.Get(*sessionRef); ok {
// We have a cached session
session = s.(*cs3Session)
if session != nil {
user, err := newCS3User(session.User())
if err != nil {
return nil, fmt.Errorf("cs3 backend get user failed to process user: %v", err)
}
return user, nil
}
}
// rebuild session
client, err := pool.GetGatewayServiceClient(b.gatewayAddr)
if err != nil {
return nil, err
}
res, err := client.Authenticate(ctx, &cs3gateway.AuthenticateRequest{
Type: "machine",
ClientId: "userid:" + userEntryID,
ClientSecret: b.machineAuthAPIKey,
})
if err != nil {
return nil, fmt.Errorf("cs3 backend get user machine authenticate rpc error: %v", err)
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return nil, fmt.Errorf("cs3 backend get user machine authenticate failed with code %s: %s", res.GetStatus().GetCode().String(), res.GetStatus().GetMessage())
}
// cache session
session = createSession(res.GetUser())
b.sessions.Set(*sessionRef, session)
user, err := newCS3User(res.GetUser())
if err != nil {
return nil, fmt.Errorf("cs3 backend get user data error: %v", err)
}
return user, nil
}
// ResolveUserByUsername implements the Backend interface, providing lookup for
// user by providing the username. Requests are bound to the provided context.
func (b *CS3Backend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
client, err := pool.GetGatewayServiceClient(b.gatewayAddr)
if err != nil {
return nil, err
}
res, err := client.Authenticate(ctx, &cs3gateway.AuthenticateRequest{
Type: "machine",
ClientId: "username:" + username,
ClientSecret: b.machineAuthAPIKey,
})
if err != nil {
return nil, fmt.Errorf("cs3 backend machine authenticate rpc error: %v", err)
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return nil, fmt.Errorf("cs3 backend machine authenticate failed with code %s: %s", res.GetStatus().GetCode().String(), res.GetStatus().GetMessage())
}
user, err := newCS3User(res.GetUser())
if err != nil {
return nil, fmt.Errorf("cs3 backend resolve username data error: %v", err)
}
return user, nil
}
// RefreshSession implements the Backend interface.
func (b *CS3Backend) RefreshSession(_ context.Context, _ string, _ *string, _ map[string]any) error {
return nil
}
// DestroySession implements the Backend interface providing destroy CS3 session.
func (b *CS3Backend) DestroySession(_ context.Context, sessionRef *string) error {
b.sessions.Remove(*sessionRef)
return nil
}
// UserClaims implements the Backend interface, providing user specific claims
// for the user specified by the userID.
func (b *CS3Backend) UserClaims(_ string, _ map[string]bool) map[string]any {
return nil
}
// ScopesSupported implements the Backend interface, providing supported scopes
// when running this backend.
func (b *CS3Backend) ScopesSupported() []string {
return b.supportedScopes
}
// ScopesMeta implements the Backend interface, providing meta data for
// supported scopes.
func (b *CS3Backend) ScopesMeta() *scopes.Scopes {
return nil
}
// Name implements the Backend interface.
func (b *CS3Backend) Name() string {
return cs3BackendName
}
@@ -0,0 +1,29 @@
package cs3
import (
"time"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
)
// createSession creates a new Session without the server using the provided
// data.
func createSession(u *cs3user.User) *cs3Session {
s := &cs3Session{
u: u,
}
s.when = time.Now()
return s
}
type cs3Session struct {
u *cs3user.User
when time.Time
}
// User returns the cs3 user of the session
func (s *cs3Session) User() *cs3user.User {
return s.u
}
@@ -0,0 +1,74 @@
package cs3
import (
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
konnect "github.com/libregraph/lico"
)
type cs3User struct {
u *cs3user.User
}
func newCS3User(u *cs3user.User) (*cs3User, error) {
return &cs3User{
u: u,
}, nil
}
// Subject returns the cs3 users opaque id as sub
func (u *cs3User) Subject() string {
return u.u.GetId().GetOpaqueId()
}
// Email returns the cs3 users email
func (u *cs3User) Email() string {
return u.u.GetMail()
}
// EmailVerified returns the cs3 users email verified flag
func (u *cs3User) EmailVerified() bool {
return u.u.GetMailVerified()
}
// Name returns the cs3 users displayname
func (u *cs3User) Name() string {
return u.u.GetDisplayName()
}
// FamilyName always returns "" to fulfill the UserWithProfile interface
func (u *cs3User) FamilyName() string {
return ""
}
// GivenName always returns "" to fulfill the UserWithProfile interface
func (u *cs3User) GivenName() string {
return ""
}
// Username returns the cs3 users username
func (u *cs3User) Username() string {
return u.u.GetUsername()
}
// UniqueID returns the cs3 users opaque id
func (u *cs3User) UniqueID() string {
return u.u.GetId().GetOpaqueId()
}
// BackendClaims returns additional claims the cs3 users provides
func (u *cs3User) BackendClaims() map[string]any {
claims := make(map[string]any)
claims[konnect.IdentifiedUserIDClaim] = u.u.GetId().GetOpaqueId()
return claims
}
// BackendScopes returns nil to fulfill the UserFromBackend interface
func (u *cs3User) BackendScopes() []string {
return nil
}
// RequiredScopes returns nil to fulfill the UserFromBackend interface
func (u *cs3User) RequiredScopes() []string {
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package command
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/config/parser"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "check health status",
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
resp, err := http.Get(
fmt.Sprintf(
"http://%s/healthz",
cfg.Debug.Addr,
),
)
if err != nil {
logger.Fatal().
Err(err).
Msg("Failed to request health check")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Fatal().
Int("code", resp.StatusCode).
Msg("Health seems to be in bad state")
}
logger.Debug().
Int("code", resp.StatusCode).
Msg("Health got a good state")
return nil
},
}
}
+37
View File
@@ -0,0 +1,37 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.Command{
// start this service
Server(cfg),
// interaction with this service
// infos about this service
Health(cfg),
Version(cfg),
}
}
// Execute is the entry point for the idp command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "idp",
Short: "Serve IDP API for КуСфера",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
+192
View File
@@ -0,0 +1,192 @@
package command
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/runner"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/config/parser"
"github.com/qsfera/server/services/idp/pkg/metrics"
"github.com/qsfera/server/services/idp/pkg/server/debug"
"github.com/qsfera/server/services/idp/pkg/server/http"
"github.com/spf13/cobra"
)
const _rsaKeySize = 4096
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
err := configlog.ReturnFatal(parser.ParseConfig(cfg))
if err != nil {
return err
}
if cfg.IDP.EncryptionSecretFile != "" {
if err := ensureEncryptionSecretExists(cfg.IDP.EncryptionSecretFile); err != nil {
return err
}
if err := ensureSigningPrivateKeyExists(cfg.IDP.SigningPrivateKeyFiles); err != nil {
return err
}
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
metrics := metrics.New()
metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1)
gr := runner.NewGroup()
{
server, err := http.Server(
http.Logger(logger),
http.Context(ctx),
http.Config(cfg),
http.Metrics(metrics),
http.TraceProvider(traceProvider),
)
if err != nil {
logger.Info().
Err(err).
Str("transport", "http").
Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
}
{
debugServer, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
func ensureEncryptionSecretExists(path string) error {
_, err := os.Stat(path)
if err == nil {
// If the file exists we can just return
return nil
}
if !errors.Is(err, fs.ErrNotExist) {
return err
}
dir := filepath.Dir(path)
err = os.MkdirAll(dir, 0o700)
if err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
secret := make([]byte, 32)
_, err = rand.Read(secret)
if err != nil {
return err
}
_, err = io.Copy(f, bytes.NewReader(secret))
if err != nil {
return err
}
return nil
}
func ensureSigningPrivateKeyExists(paths []string) error {
for _, path := range paths {
file, err := os.Stat(path)
if err == nil && file.Size() > 0 {
// If the file exists and is not empty we can just return
return nil
}
if !errors.Is(err, fs.ErrNotExist) && file.Size() > 0 {
return err
}
dir := filepath.Dir(path)
err = os.MkdirAll(dir, 0o700)
if err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
pk, err := rsa.GenerateKey(rand.Reader, _rsaKeySize)
if err != nil {
return err
}
pb := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(pk),
}
if err := pem.Encode(f, pb); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,49 @@
package command
import (
"fmt"
"os"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Compiled: %s\n", version.Compiled())
fmt.Println("")
reg := registry.GetRegistry()
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
if err != nil {
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
return err
}
if len(services) == 0 {
fmt.Println("No running " + cfg.Service.Name + " service found.")
return nil
}
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
table.Header([]string{"Version", "Address", "Id"})
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
+120
View File
@@ -0,0 +1,120 @@
package config
import (
"context"
"net/http"
"github.com/qsfera/server/pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;IDP_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
Reva *shared.Reva `yaml:"reva"`
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OC_MACHINE_AUTH_API_KEY;IDP_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary for the access to resources from other services." introductionVersion:"1.0.0"`
Asset Asset `yaml:"asset"`
IDP Settings `yaml:"idp"`
Clients []Client `yaml:"clients"`
Ldap Ldap `yaml:"ldap"`
Context context.Context `yaml:"-"`
}
// Ldap defines the available LDAP configuration.
type Ldap struct {
URI string `yaml:"uri" env:"OC_LDAP_URI;IDP_LDAP_URI" desc:"Url of the LDAP service to use as IDP." introductionVersion:"1.0.0"`
TLSCACert string `yaml:"cacert" env:"OC_LDAP_CACERT;IDP_LDAP_TLS_CACERT" desc:"Path/File name for the root CA certificate (in PEM format) used to validate TLS server certificates of the LDAP service. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
BindDN string `yaml:"bind_dn" env:"OC_LDAP_BIND_DN;IDP_LDAP_BIND_DN" desc:"LDAP DN to use for simple bind authentication with the target LDAP server." introductionVersion:"1.0.0"`
BindPassword string `yaml:"bind_password" env:"OC_LDAP_BIND_PASSWORD;IDP_LDAP_BIND_PASSWORD" desc:"Password to use for authenticating the 'bind_dn'." introductionVersion:"1.0.0"`
BaseDN string `yaml:"base_dn" env:"OC_LDAP_USER_BASE_DN;IDP_LDAP_BASE_DN" desc:"Search base DN for looking up LDAP users." introductionVersion:"1.0.0"`
Scope string `yaml:"scope" env:"OC_LDAP_USER_SCOPE;IDP_LDAP_SCOPE" desc:"LDAP search scope to use when looking up users. Supported scopes are 'base', 'one' and 'sub'." introductionVersion:"1.0.0"`
LoginAttribute string `yaml:"login_attribute" env:"IDP_LDAP_LOGIN_ATTRIBUTE" desc:"LDAP User attribute to use for login like 'uid'." introductionVersion:"1.0.0"`
EmailAttribute string `yaml:"email_attribute" env:"OC_LDAP_USER_SCHEMA_MAIL;IDP_LDAP_EMAIL_ATTRIBUTE" desc:"LDAP User email attribute like 'mail'." introductionVersion:"1.0.0"`
NameAttribute string `yaml:"name_attribute" env:"OC_LDAP_USER_SCHEMA_USERNAME;IDP_LDAP_NAME_ATTRIBUTE" desc:"LDAP User name attribute like 'displayName'." introductionVersion:"1.0.0"`
UUIDAttribute string `yaml:"uuid_attribute" env:"OC_LDAP_USER_SCHEMA_ID;IDP_LDAP_UUID_ATTRIBUTE" desc:"LDAP User UUID attribute like 'uid'." introductionVersion:"1.0.0"`
UUIDAttributeType string `yaml:"uuid_attribute_type" env:"IDP_LDAP_UUID_ATTRIBUTE_TYPE" desc:"LDAP User uuid attribute type like 'text'." introductionVersion:"1.0.0"`
UserEnabledAttribute string `yaml:"user_enabled_attribute" env:"OC_LDAP_USER_ENABLED_ATTRIBUTE;IDP_USER_ENABLED_ATTRIBUTE" desc:"LDAP Attribute to use as a flag telling if the user is enabled or disabled." introductionVersion:"1.0.0"`
Filter string `yaml:"filter" env:"OC_LDAP_USER_FILTER;IDP_LDAP_FILTER" desc:"LDAP filter to add to the default filters for user search like '(objectclass=qsferaUser)'." introductionVersion:"1.0.0"`
ObjectClass string `yaml:"objectclass" env:"OC_LDAP_USER_OBJECTCLASS;IDP_LDAP_OBJECTCLASS" desc:"LDAP User ObjectClass like 'inetOrgPerson'." introductionVersion:"1.0.0"`
}
// Asset defines the available asset configuration.
type Asset struct {
Path string `yaml:"asset" env:"IDP_ASSET_PATH" desc:"Serve IDP assets from a path on the filesystem instead of the builtin assets." introductionVersion:"1.0.0"`
LoginBackgroundUrl string `yaml:"login-background-url" env:"IDP_LOGIN_BACKGROUND_URL" desc:"Configure an alternative URL to the background image for the login page." introductionVersion:"1.0.0"`
}
type Client struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Trusted bool `yaml:"trusted"`
Secret string `yaml:"secret"`
RedirectURIs []string `yaml:"redirect_uris"`
PostLogoutRedirectURIs []string `yaml:"post_logout_redirect_uris"`
Origins []string `yaml:"origins"`
ApplicationType string `yaml:"application_type"`
}
type Settings struct {
Iss string `yaml:"iss" env:"OC_URL;OC_OIDC_ISSUER;IDP_ISS" desc:"The OIDC issuer URL to use." introductionVersion:"1.0.0"`
IdentityManager string `yaml:"identity_manager" env:"IDP_IDENTITY_MANAGER" desc:"The identity manager implementation to use. Supported identity managers are 'ldap', 'cs3', 'libregraph' and 'guest'." introductionVersion:"1.0.0"`
URIBasePath string `yaml:"uri_base_path" env:"IDP_URI_BASE_PATH" desc:"IDP uri base path (defaults to '')." introductionVersion:"1.0.0"`
SignInURI string `yaml:"sign_in_uri" env:"IDP_SIGN_IN_URI" desc:"IDP sign-in url." introductionVersion:"1.0.0"`
SignedOutURI string `yaml:"signed_out_uri" env:"IDP_SIGN_OUT_URI" desc:"IDP sign-out url." introductionVersion:"1.0.0"`
AuthorizationEndpointURI string `yaml:"authorization_endpoint_uri" env:"IDP_ENDPOINT_URI" desc:"URL of the IDP endpoint." introductionVersion:"1.0.0"`
EndsessionEndpointURI string `yaml:"-"` // unused, not supported by lico-idp
Insecure bool `yaml:"ldap_insecure" env:"OC_LDAP_INSECURE;IDP_INSECURE" desc:"Disable TLS certificate validation for the LDAP connections. Do not set this in production environments." introductionVersion:"1.0.0"`
TrustedProxy []string `yaml:"trusted_proxy"` //TODO: how to configure this via env?
AllowScope []string `yaml:"allow_scope"` // TODO: is this even needed?
AllowClientGuests bool `yaml:"allow_client_guests" env:"IDP_ALLOW_CLIENT_GUESTS" desc:"Allow guest clients to access КуСфера." introductionVersion:"1.0.0"`
AllowDynamicClientRegistration bool `yaml:"allow_dynamic_client_registration" env:"IDP_ALLOW_DYNAMIC_CLIENT_REGISTRATION" desc:"Allow dynamic client registration." introductionVersion:"1.0.0"`
EncryptionSecretFile string `yaml:"encrypt_secret_file" env:"IDP_ENCRYPTION_SECRET_FILE" desc:"Path to the encryption secret file, if unset, a new certificate will be autogenerated upon each restart, thus invalidating all existing sessions. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
Listen string
IdentifierClientDisabled bool `yaml:"-"` // unused
IdentifierClientPath string `yaml:"-"`
IdentifierRegistrationConf string `yaml:"-"`
IdentifierScopesConf string `yaml:"-"` // unused
IdentifierDefaultBannerLogo string
IdentifierDefaultSignInPageText string `yaml:"default_sign_in_page_text" env:"IDP_DEFAULT_SIGNIN_PAGE_TEXT" desc:"" introductionVersion:"2.0.0"`
IdentifierDefaultLogoTargetURI string `yaml:"default_logo_target_uri" env:"IDP_DEFAULT_LOGO_TARGET_URI" desc:"Default logo target URI." introductionVersion:"4.0.0"`
IdentifierDefaultUsernameHintText string
IdentifierUILocales []string
SigningKid string `yaml:"signing_kid" env:"IDP_SIGNING_KID" desc:"Value of the KID (Key ID) field which is used in created tokens to uniquely identify the signing-private-key." introductionVersion:"1.0.0"`
SigningMethod string `yaml:"signing_method" env:"IDP_SIGNING_METHOD" desc:"Signing method of IDP requests like 'PS256'" introductionVersion:"1.0.0"`
SigningPrivateKeyFiles []string `yaml:"signing_private_key_files" env:"IDP_SIGNING_PRIVATE_KEY_FILES" desc:"A list of private key files for signing IDP requests. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
ValidationKeysPath string `yaml:"validation_keys_path" env:"IDP_VALIDATION_KEYS_PATH" desc:"Path to validation keys for IDP requests." introductionVersion:"1.0.0"`
CookieBackendURI string
CookieNames []string
CookieSameSite http.SameSite
AccessTokenDurationSeconds uint64 `yaml:"access_token_duration_seconds" env:"IDP_ACCESS_TOKEN_EXPIRATION" desc:"'Access token lifespan in seconds (time before an access token is expired).'" introductionVersion:"1.0.0"`
IDTokenDurationSeconds uint64 `yaml:"id_token_duration_seconds" env:"IDP_ID_TOKEN_EXPIRATION" desc:"ID token lifespan in seconds (time before an ID token is expired)." introductionVersion:"1.0.0"`
RefreshTokenDurationSeconds uint64 `yaml:"refresh_token_duration_seconds" env:"IDP_REFRESH_TOKEN_EXPIRATION" desc:"Refresh token lifespan in seconds (time before an refresh token is expired). This also limits the duration of an idle offline session." introductionVersion:"1.0.0"`
DynamicClientSecretDurationSeconds uint64 `yaml:"dynamic_client_secret_duration_seconds" env:"IDP_DYNAMIC_CLIENT_SECRET_DURATION" desc:"Lifespan in seconds of a dynamically registered OIDC client." introductionVersion:"1.0.0"`
}
+9
View File
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"IDP_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"IDP_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"IDP_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"IDP_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,160 @@
package defaults
import (
"net/http"
"path/filepath"
"strings"
"github.com/qsfera/server/pkg/config/defaults"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/structs"
"github.com/qsfera/server/services/idp/pkg/config"
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// DefaultConfig returns a basic default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9134",
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9130",
Root: "/",
Namespace: "qsfera.web",
TLSCert: filepath.Join(defaults.BaseDataPath(), "idp", "server.crt"),
TLSKey: filepath.Join(defaults.BaseDataPath(), "idp", "server.key"),
TLS: false,
},
Reva: shared.DefaultRevaConfig(),
Service: config.Service{
Name: "idp",
},
IDP: config.Settings{
Iss: "https://localhost:9200",
IdentityManager: "ldap",
URIBasePath: "",
SignInURI: "",
SignedOutURI: "",
AuthorizationEndpointURI: "",
EndsessionEndpointURI: "",
Insecure: false,
TrustedProxy: nil,
AllowScope: nil,
AllowClientGuests: false,
AllowDynamicClientRegistration: false,
EncryptionSecretFile: filepath.Join(defaults.BaseDataPath(), "idp", "encryption.key"),
Listen: "",
IdentifierClientDisabled: true,
IdentifierClientPath: filepath.Join(defaults.BaseDataPath(), "idp"),
IdentifierRegistrationConf: filepath.Join(defaults.BaseDataPath(), "idp", "tmp", "identifier-registration.yaml"),
IdentifierScopesConf: "",
IdentifierDefaultBannerLogo: "",
IdentifierDefaultSignInPageText: "",
IdentifierDefaultLogoTargetURI: "",
IdentifierDefaultUsernameHintText: "",
SigningKid: "private-key",
SigningMethod: "PS256",
SigningPrivateKeyFiles: []string{filepath.Join(defaults.BaseDataPath(), "idp", "private-key.pem")},
ValidationKeysPath: "",
CookieBackendURI: "",
CookieNames: nil,
CookieSameSite: http.SameSiteStrictMode,
AccessTokenDurationSeconds: 60 * 5, // 5 minutes
IDTokenDurationSeconds: 60 * 5, // 5 minutes
RefreshTokenDurationSeconds: 60 * 60 * 24 * 30, // 30 days
DynamicClientSecretDurationSeconds: 0,
},
Clients: []config.Client{
{
ID: "web",
Name: "КуСфера Web App",
Trusted: true,
RedirectURIs: []string{
"{{OC_URL}}/",
"{{OC_URL}}/oidc-callback.html",
"{{OC_URL}}/oidc-silent-redirect.html",
},
Origins: []string{
"{{OC_URL}}",
},
},
{
ID: "QsferaDesktop",
Name: "КуСфера Desktop Client",
ApplicationType: "native",
RedirectURIs: []string{
"http://127.0.0.1",
"http://localhost",
},
},
{
ID: "QsferaAndroid",
Name: "КуСфера Android App",
ApplicationType: "native",
RedirectURIs: []string{
"oc://android.qsfera.eu",
},
PostLogoutRedirectURIs: []string{
"oc://android.qsfera.eu",
},
},
{
ID: "QsferaIOS",
Name: "КуСфера iOS App",
ApplicationType: "native",
RedirectURIs: []string{
"oc://ios.qsfera.eu",
},
PostLogoutRedirectURIs: []string{
"oc://ios.qsfera.eu",
},
},
},
Ldap: config.Ldap{
URI: "ldaps://localhost:9235",
TLSCACert: filepath.Join(defaults.BaseDataPath(), "idm", "ldap.crt"),
BindDN: "uid=idp,ou=sysusers,o=libregraph-idm",
BaseDN: "ou=users,o=libregraph-idm",
Scope: "sub",
LoginAttribute: "uid",
EmailAttribute: "mail",
NameAttribute: "displayName",
UUIDAttribute: "qsferaUUID",
UUIDAttributeType: "text",
Filter: "",
ObjectClass: "inetOrgPerson",
UserEnabledAttribute: "qsferaUserEnabled",
},
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.Reva == nil && cfg.Commons != nil {
cfg.Reva = structs.CopyOrZeroValue(cfg.Commons.Reva)
}
if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" {
cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey
}
}
// Sanitize sanitizes the configuration
func Sanitize(cfg *config.Config) {
// sanitize config
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
}
+11
View File
@@ -0,0 +1,11 @@
package config
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"IDP_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
Root string `yaml:"root" env:"IDP_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
TLSCert string `yaml:"tls_cert" env:"IDP_TRANSPORT_TLS_CERT" desc:"Path/File name of the TLS server certificate (in PEM format) for the IDP service. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
TLSKey string `yaml:"tls_key" env:"IDP_TRANSPORT_TLS_KEY" desc:"Path/File name for the TLS certificate key (in PEM format) for the server certificate to use for the IDP service. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
TLS bool `yaml:"tls" env:"IDP_TLS" desc:"Disable or Enable HTTPS for the communication between the Proxy service and the IDP service. If set to 'true', the key and cert files need to be configured and present." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,49 @@
package parser
import (
"errors"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
func Validate(cfg *config.Config) error {
switch cfg.IDP.IdentityManager {
case "cs3":
if cfg.MachineAuthAPIKey == "" {
return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
}
case "ldap":
if cfg.Ldap.BindPassword == "" {
return shared.MissingLDAPBindPassword(cfg.Service.Name)
}
}
return nil
}
@@ -0,0 +1,7 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
PasswordResetURI string `yaml:"password_reset_uri" env:"IDP_PASSWORD_RESET_URI" desc:"The URI where a user can reset their password." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,45 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "idp"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
// Counter *prometheus.CounterVec
BuildInfo *prometheus.GaugeVec
}
// New initializes the available metrics.
func New() *Metrics {
m := &Metrics{
// Counter: prometheus.NewCounterVec(prometheus.CounterOpts{
// Namespace: Namespace,
// Subsystem: Subsystem,
// Name: "greet_total",
// Help: "How many greeting requests processed",
// }, []string{}),
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build Information",
}, []string{"version"}),
}
// prometheus.Register(
// m.Counter,
// )
_ = prometheus.Register(
m.BuildInfo,
)
return m
}
@@ -0,0 +1,55 @@
// Package middleware provides middleware for the idp service.
package middleware
import (
"net/http"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// Static is a middleware that serves static assets.
func Static(root string, fs http.FileSystem, tp trace.TracerProvider) func(http.Handler) http.Handler {
if !strings.HasSuffix(root, "/") {
root = root + "/"
}
static := http.StripPrefix(
root,
http.FileServer(
fs,
),
)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
}
ctx, span := tp.Tracer("idp").Start(r.Context(), "serve static asset", spanOpts...)
defer span.End()
r = r.WithContext(ctx)
// serve the static assets for the identifier web app
if strings.HasPrefix(r.URL.Path, "/signin/v1/static/") {
if strings.HasSuffix(r.URL.Path, "/") {
// but no listing of folders
span.SetAttributes(attribute.KeyValue{Key: "path", Value: attribute.StringValue(r.URL.Path)})
span.SetStatus(codes.Error, "asset not found")
http.NotFound(w, r)
} else {
r.URL.Path = strings.Replace(r.URL.Path, "/signin/v1/static/", "/signin/v1/identifier/static/", 1)
span.SetAttributes(attribute.KeyValue{Key: "server", Value: attribute.StringValue(r.URL.Path)})
static.ServeHTTP(w, r)
}
return
}
span.SetAttributes(attribute.KeyValue{Key: "path", Value: attribute.StringValue(r.URL.Path)})
span.SetStatus(codes.Ok, "ok")
next.ServeHTTP(w, r)
})
}
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,39 @@
package debug
import (
"net/http"
"net/url"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("http reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr))
u, err := url.Parse(options.Config.Ldap.URI)
if err != nil {
return nil, err
}
readyHandlerConfiguration := healthHandlerConfiguration.
WithCheck("ldap reachability", checks.NewTCPCheck(u.Host))
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
), nil
}
@@ -0,0 +1,86 @@
package http
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/metrics"
"github.com/spf13/pflag"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Namespace string
Logger log.Logger
Context context.Context
Config *config.Config
Metrics *metrics.Metrics
Flags []pflag.Flag
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Metrics provides a function to set the metrics option.
func Metrics(val *metrics.Metrics) Option {
return func(o *Options) {
o.Metrics = val
}
}
// Flags provides a function to set the flags option.
func Flags(flags ...pflag.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// Namespace provides a function to set the namespace option.
func Namespace(val string) Option {
return func(o *Options) {
o.Namespace = val
}
}
// TraceProvider provides a function to set the trace provider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
@@ -0,0 +1,85 @@
package http
import (
"fmt"
"os"
chimiddleware "github.com/go-chi/chi/v5/middleware"
pkgcrypto "github.com/qsfera/server/pkg/crypto"
"github.com/qsfera/server/pkg/middleware"
"github.com/qsfera/server/pkg/service/http"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/version"
svc "github.com/qsfera/server/services/idp/pkg/service/v0"
"go-micro.dev/v4"
)
// Server initializes the http service and server.
func Server(opts ...Option) (http.Service, error) {
options := newOptions(opts...)
if options.Config.HTTP.TLS {
_, certErr := os.Stat(options.Config.HTTP.TLSCert)
_, keyErr := os.Stat(options.Config.HTTP.TLSKey)
if os.IsNotExist(certErr) || os.IsNotExist(keyErr) {
options.Logger.Info().Msgf("Generating certs")
if err := pkgcrypto.GenCert(options.Config.HTTP.TLSCert, options.Config.HTTP.TLSKey, options.Logger); err != nil {
options.Logger.Fatal().Err(err).Msg("Could not setup TLS")
os.Exit(1)
}
}
}
service, err := http.NewService(
http.Logger(options.Logger),
http.Namespace(options.Config.HTTP.Namespace),
http.Name(options.Config.Service.Name),
http.Version(version.GetString()),
http.Address(options.Config.HTTP.Addr),
http.Context(options.Context),
http.Flags(options.Flags...),
http.TLSConfig(shared.HTTPServiceTLS{
Enabled: options.Config.HTTP.TLS,
Cert: options.Config.HTTP.TLSCert,
Key: options.Config.HTTP.TLSKey,
}),
http.TraceProvider(options.TraceProvider),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
}
handle := svc.NewService(
svc.Logger(options.Logger),
svc.Config(options.Config),
svc.Middleware(
chimiddleware.RealIP,
chimiddleware.RequestID,
middleware.TraceContext,
middleware.NoCache,
middleware.Version(
options.Config.Service.Name,
version.GetString(),
),
middleware.Logger(
options.Logger,
),
),
svc.TraceProvider(options.TraceProvider),
)
{
handle = svc.NewInstrument(handle, options.Metrics)
handle = svc.NewLoggingHandler(handle, options.Logger)
}
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
return http.Service{}, err
}
return service, nil
}
@@ -0,0 +1,25 @@
package svc
import (
"net/http"
"github.com/qsfera/server/services/idp/pkg/metrics"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// ServeHTTP implements the Service interface.
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
i.next.ServeHTTP(w, r)
}
@@ -0,0 +1,25 @@
package svc
import (
"net/http"
"github.com/qsfera/server/pkg/log"
)
// NewLogging returns a service that logs messages.
func NewLoggingHandler(next Service, logger log.Logger) Service {
return loggingHandler{
next: next,
logger: logger,
}
}
type loggingHandler struct {
next Service
logger log.Logger
}
// ServeHTTP implements the Service interface.
func (l loggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l.next.ServeHTTP(w, r)
}
@@ -0,0 +1,59 @@
package svc
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Middleware provides a function to set the middleware option.
func Middleware(val ...func(http.Handler) http.Handler) Option {
return func(o *Options) {
o.Middleware = val
}
}
// TraceProvider provides a function to set the trace provider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
@@ -0,0 +1,346 @@
package svc
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"github.com/go-chi/chi/v5"
"github.com/gorilla/mux"
"github.com/libregraph/lico/bootstrap"
guestBackendSupport "github.com/libregraph/lico/bootstrap/backends/guest"
ldapBackendSupport "github.com/libregraph/lico/bootstrap/backends/ldap"
libreGraphBackendSupport "github.com/libregraph/lico/bootstrap/backends/libregraph"
licoconfig "github.com/libregraph/lico/config"
"github.com/libregraph/lico/server"
"github.com/qsfera/server/pkg/ldap"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/services/idp/pkg/assets"
cs3BackendSupport "github.com/qsfera/server/services/idp/pkg/backends/cs3/bootstrap"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/middleware"
"github.com/riandyrn/otelchi"
"go.opentelemetry.io/otel/trace"
"gopkg.in/yaml.v2"
"stash.kopano.io/kgol/rndm"
)
// Service defines the service handlers.
type Service interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
// NewService returns a service implementation for Service.
func NewService(opts ...Option) Service {
ctx := context.Background()
options := newOptions(opts...)
logger := options.Logger.Logger
assetVFS := assets.New(
assets.Logger(options.Logger),
assets.Config(options.Config),
)
if err := createTemporaryClientsConfig(
options.Config.IDP.IdentifierRegistrationConf,
options.Config.Commons.QsferaURL,
options.Config.Clients,
); err != nil {
logger.Fatal().Err(err).Msg("could not create default config")
}
switch options.Config.IDP.IdentityManager {
case "cs3":
cs3BackendSupport.MustRegister()
if err := initCS3EnvVars(options.Config.Reva.Address, options.Config.MachineAuthAPIKey); err != nil {
logger.Fatal().Err(err).Msg("could not initialize cs3 backend env vars")
}
case "ldap":
if err := ldap.WaitForCA(options.Logger, options.Config.IDP.Insecure, options.Config.Ldap.TLSCACert); err != nil {
logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
}
if options.Config.IDP.Insecure {
// force CACert to be empty to avoid lico try to load it
options.Config.Ldap.TLSCACert = ""
}
ldapBackendSupport.MustRegister()
if err := initLicoInternalLDAPEnvVars(&options.Config.Ldap); err != nil {
logger.Fatal().Err(err).Msg("could not initialize ldap env vars")
}
default:
guestBackendSupport.MustRegister()
libreGraphBackendSupport.MustRegister()
}
idpSettings := bootstrap.Settings{
Iss: options.Config.IDP.Iss,
IdentityManager: options.Config.IDP.IdentityManager,
URIBasePath: options.Config.IDP.URIBasePath,
SignInURI: options.Config.IDP.SignInURI,
SignedOutURI: options.Config.IDP.SignedOutURI,
AuthorizationEndpointURI: options.Config.IDP.AuthorizationEndpointURI,
EndsessionEndpointURI: options.Config.IDP.EndsessionEndpointURI,
Insecure: options.Config.IDP.Insecure,
TrustedProxy: options.Config.IDP.TrustedProxy,
AllowScope: options.Config.IDP.AllowScope,
AllowClientGuests: options.Config.IDP.AllowClientGuests,
AllowDynamicClientRegistration: options.Config.IDP.AllowDynamicClientRegistration,
EncryptionSecretFile: options.Config.IDP.EncryptionSecretFile,
Listen: options.Config.IDP.Listen,
IdentifierClientDisabled: options.Config.IDP.IdentifierClientDisabled,
IdentifierClientPath: options.Config.IDP.IdentifierClientPath,
IdentifierRegistrationConf: options.Config.IDP.IdentifierRegistrationConf,
IdentifierScopesConf: options.Config.IDP.IdentifierScopesConf,
IdentifierDefaultBannerLogo: options.Config.IDP.IdentifierDefaultBannerLogo,
IdentifierDefaultSignInPageText: options.Config.IDP.IdentifierDefaultSignInPageText,
IdentifierDefaultLogoTargetURI: options.Config.IDP.IdentifierDefaultLogoTargetURI,
IdentifierDefaultUsernameHintText: options.Config.IDP.IdentifierDefaultUsernameHintText,
IdentifierUILocales: options.Config.IDP.IdentifierUILocales,
SigningKid: options.Config.IDP.SigningKid,
SigningMethod: options.Config.IDP.SigningMethod,
SigningPrivateKeyFiles: options.Config.IDP.SigningPrivateKeyFiles,
ValidationKeysPath: options.Config.IDP.ValidationKeysPath,
CookieBackendURI: options.Config.IDP.CookieBackendURI,
CookieNames: options.Config.IDP.CookieNames,
CookieSameSite: options.Config.IDP.CookieSameSite,
AccessTokenDurationSeconds: options.Config.IDP.AccessTokenDurationSeconds,
IDTokenDurationSeconds: options.Config.IDP.IDTokenDurationSeconds,
RefreshTokenDurationSeconds: options.Config.IDP.RefreshTokenDurationSeconds,
DyamicClientSecretDurationSeconds: options.Config.IDP.DynamicClientSecretDurationSeconds,
}
bs, err := bootstrap.Boot(ctx, &idpSettings, &licoconfig.Config{
Logger: log.LogrusWrap(logger),
})
if err != nil {
logger.Fatal().Err(err).Msg("could not bootstrap idp")
}
managers := bs.Managers()
routes := []server.WithRoutes{managers.Must("identity").(server.WithRoutes)}
handlers := managers.Must("handler").(http.Handler)
svc := IDP{
logger: options.Logger,
config: options.Config,
assets: assetVFS,
tp: options.TraceProvider,
}
svc.initMux(ctx, routes, handlers, options)
return svc
}
type temporaryClientConfig struct {
Clients []config.Client `yaml:"clients"`
}
func createTemporaryClientsConfig(filePath, qsferaURL string, clients []config.Client) error {
folder := path.Dir(filePath)
if _, err := os.Stat(folder); os.IsNotExist(err) {
if err := os.MkdirAll(folder, 0o700); err != nil {
return err
}
}
for i, client := range clients {
for i, entry := range client.RedirectURIs {
client.RedirectURIs[i] = strings.ReplaceAll(entry, "{{OC_URL}}", strings.TrimRight(qsferaURL, "/"))
}
for i, entry := range client.Origins {
client.Origins[i] = strings.ReplaceAll(entry, "{{OC_URL}}", strings.TrimRight(qsferaURL, "/"))
}
clients[i] = client
}
c := temporaryClientConfig{
Clients: clients,
}
conf, err := yaml.Marshal(c)
if err != nil {
return err
}
confOnDisk, err := os.Create(filePath)
if err != nil {
return err
}
defer confOnDisk.Close()
err = os.WriteFile(filePath, conf, 0o600)
if err != nil {
return err
}
return nil
}
// Init cs3 backend vars which are currently not accessible via idp api
func initCS3EnvVars(cs3Addr, machineAuthAPIKey string) error {
defaults := map[string]string{
"CS3_GATEWAY": cs3Addr,
"CS3_MACHINE_AUTH_API_KEY": machineAuthAPIKey,
}
for k, v := range defaults {
if err := os.Setenv(k, v); err != nil {
return fmt.Errorf("could not set cs3 env var %s=%s", k, v)
}
}
return nil
}
// Init ldap backend vars which are currently not accessible via idp api
func initLicoInternalLDAPEnvVars(ldap *config.Ldap) error {
filter := fmt.Sprintf("(objectclass=%s)", ldap.ObjectClass)
var needsAnd bool
if ldap.Filter != "" {
filter += ldap.Filter
needsAnd = true
}
if ldap.UserEnabledAttribute != "" {
// Using a (!(enabled=FALSE)) filter here to allow user without
// any value for the enable flag to log in
filter += fmt.Sprintf("(!(%s=FALSE))", ldap.UserEnabledAttribute)
needsAnd = true
}
if needsAnd {
filter = fmt.Sprintf("(&%s)", filter)
}
defaults := map[string]string{
"LDAP_URI": ldap.URI,
"LDAP_BINDDN": ldap.BindDN,
"LDAP_BINDPW": ldap.BindPassword,
"LDAP_BASEDN": ldap.BaseDN,
"LDAP_SCOPE": ldap.Scope,
"LDAP_LOGIN_ATTRIBUTE": ldap.LoginAttribute,
"LDAP_EMAIL_ATTRIBUTE": ldap.EmailAttribute,
"LDAP_NAME_ATTRIBUTE": ldap.NameAttribute,
"LDAP_UUID_ATTRIBUTE": ldap.UUIDAttribute,
"LDAP_SUB_ATTRIBUTES": ldap.UUIDAttribute,
"LDAP_UUID_ATTRIBUTE_TYPE": ldap.UUIDAttributeType,
"LDAP_FILTER": filter,
}
if ldap.TLSCACert != "" {
defaults["LDAP_TLS_CACERT"] = ldap.TLSCACert
}
for k, v := range defaults {
if err := os.Setenv(k, v); err != nil {
return fmt.Errorf("could not set ldap env var %s=%s", k, v)
}
}
return nil
}
// IDP defines implements the business logic for Service.
type IDP struct {
logger log.Logger
config *config.Config
mux *chi.Mux
assets http.FileSystem
tp trace.TracerProvider
}
// initMux initializes the internal idp gorilla mux and mounts it in to an КуСфера chi-router
func (idp *IDP) initMux(ctx context.Context, r []server.WithRoutes, h http.Handler, options Options) {
gm := mux.NewRouter()
for _, route := range r {
route.AddRoutes(ctx, gm)
}
// Delegate rest to provider which is also a handler.
if h != nil {
gm.NotFoundHandler = h
}
idp.mux = chi.NewMux()
idp.mux.Use(options.Middleware...)
idp.mux.Use(middleware.Static(
"/signin/v1/",
assets.New(
assets.Logger(options.Logger),
assets.Config(options.Config),
),
idp.tp,
))
idp.mux.Use(
otelchi.Middleware(
"idp",
otelchi.WithChiRoutes(idp.mux),
otelchi.WithTracerProvider(idp.tp),
otelchi.WithPropagators(tracing.GetPropagator()),
),
)
// handle / | index.html with a template that needs to have the BASE_PREFIX replaced
idp.mux.Get("/signin/v1/identifier", idp.Index())
idp.mux.Get("/signin/v1/identifier/", idp.Index())
idp.mux.Get("/signin/v1/identifier/index.html", idp.Index())
idp.mux.Mount("/", gm)
_ = chi.Walk(idp.mux, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
return nil
})
}
// ServeHTTP implements the Service interface.
func (idp IDP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
idp.mux.ServeHTTP(w, r)
}
// Index renders the static html with templated variables.
func (idp IDP) Index() http.HandlerFunc {
f, err := idp.assets.Open("/identifier/index.html")
if err != nil {
idp.logger.Fatal().Err(err).Msg("Could not open index template")
}
template, err := io.ReadAll(f)
if err != nil {
idp.logger.Fatal().Err(err).Msg("Could not read index template")
}
if err = f.Close(); err != nil {
idp.logger.Fatal().Err(err).Msg("Could not close body")
}
// TODO add environment variable to make the path prefix configurable
pp := "/signin/v1"
indexHTML := bytes.Replace(template, []byte("__PATH_PREFIX__"), []byte(pp), 1)
background := idp.config.Asset.LoginBackgroundUrl
indexHTML = bytes.Replace(template, []byte("__BG_IMG_URL__"), []byte(background), 1)
nonce := rndm.GenerateRandomString(32)
indexHTML = bytes.Replace(indexHTML, []byte("__CSP_NONCE__"), []byte(nonce), 1)
indexHTML = bytes.Replace(indexHTML, []byte("__PASSWORD_RESET_LINK__"), []byte(idp.config.Service.PasswordResetURI), 1)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write(indexHTML); err != nil {
idp.logger.Error().Err(err).Msg("could not write to response writer")
}
})
}
+10519
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head data-kopano-build="%REACT_APP_KOPANO_BUILD%">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#1b223d">
<link rel="icon" href="%PUBLIC_URL%/static/favicon.svg" type="image/svg+xml">
<meta property="csp-nonce" content="__CSP_NONCE__">
<title>Sign in - КуСфера</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<main id="root" data-path-prefix="__PATH_PREFIX__" passwort-reset-link="__PASSWORD_RESET_LINK__" data-bg-img="__BG_IMG_URL__"></main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 180 180" role="img" aria-label="КуСфера">
<path fill="#061D2B" d="M34,12h112C158,12 168,22 168,34v112C168,158 158,168 146,168H34C22,168 12,158 12,146V34C12,22 22,12 34,12z"/>
<path fill="#123E55" fill-opacity="0.74" d="M12,45C48,19 87,20 121,48C145,68 158,68 168,60V34C168,22 158,12 146,12H34C22,12 12,22 12,34z"/>
<path fill="#19B8C9" fill-opacity="0.18" d="M12,126C46,105 82,104 114,124C139,139 156,139 168,128V146C168,158 158,168 146,168H34C22,168 12,158 12,146z"/>
<path fill="none" stroke="#20D6E6" stroke-linecap="round" stroke-width="10" d="M75,47C100,29 138,39 153,70C167,100 151,135 121,147"/>
<path fill="none" stroke="#9AF8FF" stroke-linecap="round" stroke-width="4" d="M81,45C101,35 130,39 146,62"/>
<path fill="#21D1D6" d="M48,52h25v50h-25z"/>
<path fill="#F8FCFF" d="M48,102h25v42h-25z"/>
<path fill="#18BFD0" d="M77,94L111,55H143L99,103z"/>
<path fill="#F8FCFF" d="M77,104L100,82L148,144H116z"/>
<path fill="#9DF8FF" fill-opacity="0.45" d="M48,52h25v8h-25z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+211
View File
@@ -0,0 +1,211 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../ui_config/env');
const path = require('path');
const chalk = require('react-dev-utils/chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
const configFactory = require('../ui_config/webpack.config');
const paths = require('../ui_config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const measureFileSizesBeforeBuild =
FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
const usePnpm = fs.existsSync(paths.pnpmLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
// Generate configuration
const config = configFactory('production');
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
return measureFileSizesBeforeBuild(paths.appBuild);
})
.then(previousFileSizes => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appBuild);
// Merge with the public folder
copyPublicFolder();
// Start the webpack build
return build(previousFileSizes);
})
.then(
({ stats, previousFileSizes, warnings }) => {
if (warnings.length) {
console.log(chalk.yellow('Compiled with warnings.\n'));
console.log(warnings.join('\n\n'));
console.log(
'\nSearch for the ' +
chalk.underline(chalk.yellow('keywords')) +
' to learn more about each warning.'
);
console.log(
'To ignore, add ' +
chalk.cyan('// eslint-disable-next-line') +
' to the line before.\n'
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
}
console.log('File sizes after gzip:\n');
printFileSizesAfterBuild(
stats,
previousFileSizes,
paths.appBuild,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE
);
console.log();
const appPackage = require(paths.appPackageJson);
const publicUrl = paths.publicUrl;
const publicPath = config.output.publicPath;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
publicUrl,
publicPath,
buildFolder,
usePnpm
);
},
err => {
const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
if (tscCompileOnError) {
console.log(
chalk.yellow(
'Compiled with the following type errors (you may want to check these before deploying your app):\n'
)
);
printBuildError(err);
} else {
console.log(chalk.red('Failed to compile.\n'));
printBuildError(err);
process.exit(1);
}
}
)
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
// We used to support resolving modules according to `NODE_PATH`.
// This now has been deprecated in favor of jsconfig/tsconfig.json
// This lets you use absolute paths in imports inside large monorepos:
if (process.env.NODE_PATH) {
console.log(
chalk.yellow(
'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.'
)
);
console.log();
}
console.log('Creating an optimized production build...');
const compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
if (err) {
if (!err.message) {
return reject(err);
}
let errMessage = err.message;
// Add additional information for postcss errors
if (Object.prototype.hasOwnProperty.call(err, 'postcssNode')) {
errMessage +=
'\nCompileError: Begins at CSS selector ' +
err['postcssNode'].selector;
}
messages = formatWebpackMessages({
errors: [errMessage],
warnings: [],
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true })
);
}
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
if (messages.errors.length > 1) {
messages.errors.length = 1;
}
return reject(new Error(messages.errors.join('\n\n')));
}
if (
process.env.CI &&
(typeof process.env.CI !== 'string' ||
process.env.CI.toLowerCase() !== 'false') &&
messages.warnings.length
) {
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n' +
'Most CI servers set it automatically.\n'
)
);
return reject(new Error(messages.warnings.join('\n\n')));
}
return resolve({
stats,
previousFileSizes,
warnings: messages.warnings,
});
});
});
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appBuild, {
dereference: true,
filter: file => file !== paths.appHtml,
});
}
+147
View File
@@ -0,0 +1,147 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../ui_config/env');
const fs = require('fs');
const chalk = require('react-dev-utils/chalk');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const clearConsole = require('react-dev-utils/clearConsole');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const openBrowser = require('react-dev-utils/openBrowser');
const paths = require('../ui_config/paths');
const configFactory = require('../ui_config/webpack.config');
const createDevServerConfig = require('../ui_config/webpackDevServer.config');
const usePnpm = fs.existsSync(paths.pnpmLockFile);
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
// Tools like Cloud9 rely on this.
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {
console.log(
chalk.cyan(
`Attempting to bind to HOST environment variable: ${chalk.yellow(
chalk.bold(process.env.HOST)
)}`
)
);
console.log(
`If this was unintentional, check that you haven't mistakenly set it in your shell.`
);
console.log(
`Learn more here: ${chalk.yellow('https://bit.ly/CRA-advanced-config')}`
);
console.log();
}
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
return choosePort(HOST, DEFAULT_PORT);
})
.then(port => {
if (port == null) {
// We have not found a port.
return;
}
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
const useTypeScript = fs.existsSync(paths.appTsConfig);
const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
const urls = prepareUrls(protocol, HOST, port);
const devSocket = {
warnings: warnings =>
devServer.sockWrite(devServer.sockets, 'warnings', warnings),
errors: errors =>
devServer.sockWrite(devServer.sockets, 'errors', errors),
};
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({
appName,
config,
devSocket,
urls,
usePnpm,
useTypeScript,
tscCompileOnError,
webpack,
});
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
);
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, err => {
if (err) {
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
// We used to support resolving modules according to `NODE_PATH`.
// This now has been deprecated in favor of jsconfig/tsconfig.json
// This lets you use absolute paths in imports inside large monorepos:
if (process.env.NODE_PATH) {
console.log(
chalk.yellow(
'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.'
)
);
console.log();
}
console.log(chalk.cyan('Starting the development server...\n'));
openBrowser(urls.localUrlForBrowser);
});
['SIGINT', 'SIGTERM'].forEach(function(sig) {
process.on(sig, function() {
devServer.close();
process.exit();
});
});
})
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
+73
View File
@@ -0,0 +1,73 @@
import React, {ReactElement, Suspense, lazy, useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import {MuiThemeProvider} from '@material-ui/core/styles';
import muiTheme from './theme';
import Spinner from './components/Spinner';
import * as version from './version';
import {QsferaContext} from './qsferaContext';
const LazyMain = lazy(() => import(/* webpackChunkName: "identifier-main" */ './Main'));
console.info(`Kopano Identifier build version: ${version.build}`); // eslint-disable-line no-console
const App = ({ bgImg }): ReactElement => {
const [theme, setTheme] = useState(null);
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const configResponse = await fetch('/config.json');
const configData = await configResponse.json();
setConfig(configData);
const themeResponse = await fetch(configData.theme);
const themeData = await themeResponse.json();
setTheme(themeData);
} catch (error) {
console.error('Error fetching config/theme data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return <Spinner/>;
}
return (
<QsferaContext.Provider value={{theme, config}}>
<div
className={`oc-login-bg ${bgImg ? 'oc-login-bg-image' : ''}`}
style={{backgroundImage: bgImg ? `url(${bgImg})` : undefined}}
>
<MuiThemeProvider theme={muiTheme}>
<Suspense fallback={<Spinner/>}>
<LazyMain/>
</Suspense>
</MuiThemeProvider>
{!bgImg &&
<img
src={`${process.env.PUBLIC_URL}/static/icon-lilac.svg`}
className={'oc-login-bg-icon'}
alt=''
/>
}
</div>
</QsferaContext.Provider>
);
}
App.propTypes = {
bgImg: PropTypes.string
};
export default App;
+45
View File
@@ -0,0 +1,45 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import Routes from './Routes';
class Main extends PureComponent {
render() {
const { hello, pathPrefix } = this.props;
return (
<BrowserRouter basename={pathPrefix}>
<Routes hello={hello}/>
</BrowserRouter>
);
}
reload(event) {
event.preventDefault();
window.location.reload();
}
}
Main.propTypes = {
classes: PropTypes.object.isRequired,
hello: PropTypes.object,
updateAvailable: PropTypes.bool.isRequired,
pathPrefix: PropTypes.string.isRequired
};
const mapStateToProps = (state) => {
const { hello, updateAvailable, pathPrefix } = state.common;
return {
hello,
updateAvailable,
pathPrefix
};
};
export default connect(mapStateToProps)(Main);
+8
View File
@@ -0,0 +1,8 @@
all: images
.PHONY: images
images:
@$(MAKE) -C images
clean:
@$(MAKE) -C images clean
+39
View File
@@ -0,0 +1,39 @@
import React, { ReactElement, lazy } from 'react';
import PropTypes from 'prop-types';
import { Route, Switch } from 'react-router-dom';
import PrivateRoute from './components/PrivateRoute';
const AsyncLogin = lazy(() =>
import(/* webpackChunkName: "containers-login" */ './containers/Login'));
const AsyncWelcome = lazy(() =>
import(/* webpackChunkName: "containers-welcome" */ './containers/Welcome'));
const AsyncGoodbye = lazy(() =>
import(/* webpackChunkName: "containers-goodbye" */ './containers/Goodbye'));
const Routes = ({ hello }: {hello: PropTypes.object}): ReactElement => (
<Switch>
<PrivateRoute
path="/welcome"
exact
component={AsyncWelcome}
hello={hello}
/>
<Route
path="/goodbye"
exact
component={AsyncGoodbye}
/>
<Route
path="/"
component={AsyncLogin}
/>
</Switch>
);
Routes.propTypes = {
hello: PropTypes.object
};
export default Routes;
+133
View File
@@ -0,0 +1,133 @@
import axios from 'axios';
import { newHelloRequest } from '../models/hello';
import { withClientRequestState } from '../utils';
import {
ExtendedError,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATE
} from '../errors';
import { handleAxiosError } from './utils';
import * as types from './types';
export function receiveError(error) {
return {
type: types.RECEIVE_ERROR,
error
};
}
export function resetHello() {
return {
type: types.RESET_HELLO
};
}
export function receiveHello(hello) {
const { success, username, displayName } = hello;
return {
type: types.RECEIVE_HELLO,
state: success === true,
username,
displayName,
hello
};
}
export function executeHello() {
return function(dispatch, getState) {
dispatch(resetHello());
const { flow, query } = getState().common;
const r = withClientRequestState(newHelloRequest(flow, query));
return axios.post('./identifier/_/hello', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
case 204:
// not signed-in.
return {
success: false,
state: response.headers['kopano-konnect-state']
};
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
dispatch(receiveHello(response));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
dispatch(receiveError(error));
});
};
}
export function retryHello() {
return function(dispatch) {
dispatch(receiveError(null));
return dispatch(executeHello());
};
}
export function requestLogoff() {
return {
type: types.REQUEST_LOGOFF
};
}
export function receiveLogoff(state) {
return {
type: types.RECEIVE_LOGOFF,
state
};
}
export function executeLogoff() {
return function(dispatch) {
dispatch(resetHello());
dispatch(requestLogoff());
const r = withClientRequestState({});
return axios.post('./identifier/_/logoff', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
dispatch(receiveLogoff(response.success === true));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
dispatch(receiveError(error));
});
};
}
+293
View File
@@ -0,0 +1,293 @@
import axios from 'axios';
import queryString from 'query-string';
import { newHelloRequest } from '../models/hello';
import { withClientRequestState } from '../utils';
import {
ExtendedError,
ERROR_LOGIN_VALIDATE_MISSINGUSERNAME,
ERROR_LOGIN_VALIDATE_MISSINGPASSWORD,
ERROR_LOGIN_FAILED,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATE
} from '../errors';
import * as types from './types';
import { receiveHello } from './common';
import { handleAxiosError } from './utils';
// Modes for logon.
export const ModeLogonUsernameEmptyPasswordCookie = '0';
export const ModeLogonUsernamePassword = '1';
export function updateInput(name, value) {
return {
type: types.UPDATE_INPUT,
name,
value
};
}
export function receiveValidateLogon(errors) {
return {
type: types.RECEIVE_VALIDATE_LOGON,
errors
};
}
export function requestLogon(username, password) {
return {
type: types.REQUEST_LOGON,
username,
password
};
}
export function receiveLogon(logon) {
const { success, errors } = logon;
return {
type: types.RECEIVE_LOGON,
success,
errors
};
}
export function requestConsent(allow=false) {
return {
type: allow ? types.REQUEST_CONSENT_ALLOW : types.REQUEST_CONSENT_CANCEL
};
}
export function receiveConsent(logon) {
const { success, errors } = logon;
return {
type: types.RECEIVE_CONSENT,
success,
errors
};
}
export function executeLogon(username, password, mode=ModeLogonUsernamePassword) {
return function(dispatch, getState) {
dispatch(requestLogon(username, password));
dispatch(receiveHello({
username
})); // Reset any hello state on logon.
const { flow, query } = getState().common;
// Prepare params based on mode.
const params = [];
switch (mode) {
case ModeLogonUsernamePassword:
// Username with password.
params.push(username, password, mode);
break;
case ModeLogonUsernameEmptyPasswordCookie:
// Username with empty password - this only works when the user is already signed in.
params.push(username, '', mode);
break;
// no default
}
const r = withClientRequestState({
params: params,
hello: newHelloRequest(flow, query)
});
return axios.post('./identifier/_/logon', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
case 204:
// login failed.
return {
success: false,
state: response.headers['kopano-konnect-state'],
errors: {
http: new Error(ERROR_LOGIN_FAILED)
}
};
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
let { hello } = response;
if (!hello) {
hello = {
success: response.success,
username
};
}
dispatch(receiveHello(hello));
dispatch(receiveLogon(response));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
const errors = {
http: error
};
dispatch(receiveValidateLogon(errors));
return {
success: false,
errors: errors
};
});
};
}
export function executeConsent(allow=false, scope='') {
return function(dispatch, getState) {
dispatch(requestConsent(allow));
const { query } = getState().common;
const r = withClientRequestState({
allow,
scope,
client_id: query.client_id || '', // eslint-disable-line camelcase
redirect_uri: query.redirect_uri || '', // eslint-disable-line camelcase
ref: query.state || '',
flow_nonce: query.nonce || '' // eslint-disable-line camelcase
});
return axios.post('./identifier/_/consent', r, {
headers: {
'Kopano-Konnect-XSRF': '1'
}
}).then(response => {
switch (response.status) {
case 200:
// success.
return response.data;
case 204:
// cancel reply.
return {
success: true,
state: response.headers['kopano-konnect-state']
};
default:
// error.
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, response);
}
}).then(response => {
if (response.state !== r.state) {
throw new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATE, response);
}
dispatch(receiveConsent(response));
return Promise.resolve(response);
}).catch(error => {
error = handleAxiosError(error);
const errors = {
http: error
};
dispatch(receiveValidateLogon(errors));
return {
success: false,
errors: errors
};
});
};
}
export function validateUsernamePassword(username, password, isSignedIn) {
return function(dispatch) {
return new Promise((resolve, reject) => {
const errors = {};
if (!username) {
errors.username = new Error(ERROR_LOGIN_VALIDATE_MISSINGUSERNAME);
}
if (!password && !isSignedIn) {
errors.password = new Error(ERROR_LOGIN_VALIDATE_MISSINGPASSWORD);
}
dispatch(receiveValidateLogon(errors));
if (Object.keys(errors).length === 0) {
resolve(errors);
} else {
reject(errors);
}
});
};
}
export function executeLogonIfFormValid(username, password, isSignedIn) {
return (dispatch) => {
return dispatch(
validateUsernamePassword(username, password, isSignedIn)
).then(() => {
const mode = isSignedIn ? ModeLogonUsernameEmptyPasswordCookie : ModeLogonUsernamePassword;
return dispatch(executeLogon(username, password, mode));
}).catch((errors) => {
return {
success: false,
errors: errors
};
});
};
}
export function advanceLogonFlow(success, history, done=false, extraQuery={}) {
return (dispatch, getState) => {
if (!success) {
return;
}
const { flow, query, hello } = getState().common;
const q = Object.assign({}, query, extraQuery);
switch (flow) {
case 'oauth':
case 'consent':
case 'oidc':
if (hello.details.flow !== flow) {
// Ignore requested flow if hello flow does not match.
break;
}
if (!done && hello.details.next === 'consent') {
history.replace(`/consent${history.location.search}${history.location.hash}`);
return;
}
if (hello.details.continue_uri) {
q.prompt = 'none';
window.location.replace(hello.details.continue_uri + '?' + queryString.stringify(q));
return;
}
break;
default:
// Legacy stupid modes.
if (q.continue && q.continue.indexOf(document.location.origin) === 0) {
window.location.replace(q.continue);
return;
}
}
// Default action.
let target = '/welcome';
if (history.action === 'REPLACE') {
target = target + history.location.search + history.location.hash;
}
dispatch(receiveValidateLogon({})); // XXX(longsleep): hack to reset loading and errors.
history.push(target);
};
}
+25
View File
@@ -0,0 +1,25 @@
export const RECEIVE_ERROR = 'RECEIVE_ERROR';
export const RESET_HELLO = 'RESET_HELLO';
export const EXECUTE_HELLO = 'EXECUTE_HELLO';
export const RECEIVE_HELLO = 'RECEIVE_HELLO';
export const RECEIVE_VALIDATE_LOGON = 'RECEIVE_VALIDATE_LOGON';
export const REQUEST_LOGON = 'REQUEST_LOGON';
export const EXECUTE_LOGON = 'EXECUTE_LOGON';
export const RECEIVE_LOGON = 'RECEIVE_LOGON';
export const UPDATE_INPUT = 'UPDATE_INPUT';
export const REQUEST_CONSENT_ALLOW = 'REQUEST_CONSENT_ALLOW';
export const REQUEST_CONSENT_CANCEL = 'REQUEST_CONSENT_CANCEL';
export const EXECUTE_CONSENT = 'EXECUTE_CONSENT';
export const RECEIVE_CONSENT = 'RECEIVE_CONSENT';
export const REQUEST_LOGOFF = 'REQUEST_LOGOFF';
export const EXECUTE_LOGOFF = 'EXECUTE_LOGOFF';
export const RECEIVE_LOGOFF = 'RECEIVE_LOGOFF';
export const SERVICE_WORKER_NEW_CONTENT = 'SERVICE_WORKER_NEW_CONTENT';
export const SERVICE_WORKER_READY = 'SERVICE_WORKER_READY';
export const SERVICE_WORKER_ERROR = 'SERVICE_WORKER_ERROR';
export const SERVICE_WORKER_OFFLINE = 'SERVICE_WORKER_OFFLINE';
+18
View File
@@ -0,0 +1,18 @@
import {
ExtendedError,
ERROR_HTTP_NETWORK_ERROR,
ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS
} from '../errors';
export function handleAxiosError(error) {
if (error.request) {
// Axios errors.
if (error.response) {
error = new ExtendedError(ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS, error.response);
} else {
error = new ExtendedError(ERROR_HTTP_NETWORK_ERROR);
}
}
return error;
}
+221
View File
@@ -0,0 +1,221 @@
@font-face {
font-family: QSfera;
src: url('./fonts/QSfera500-Regular.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: QSfera;
src: url('./fonts/QSfera750-Bold.woff2') format('woff2');
font-weight: bold;
font-style: normal;
}
html {
font-feature-settings: "cv11";
color: #20434f !important;
height: 100%;
}
body {
font-family: QSfera, sans-serif;
height: 100%;
margin: 0;
padding: 0;
}
strong {
font-weight: 600;
}
#root {
height: 100%;
display: flex;
}
.oc-font-weight-light {
font-weight: 300;
}
.oc-login-bg {
background: #20434F;
display: flex;
width: 100%;
}
.oc-login-bg-icon {
position: fixed;
right: -3vw;
bottom: -3vh;
height: 50vh;
z-index: 0;
}
.oc-login-bg-image {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
#loader {
/* NOTE(longsleep): White here needed because of the background image */
color: white;
text-shadow: #000 0 0 1px;
}
.oc-logo {
height: 80px;
z-index: 0;
}
.oc-logo-container {
margin-bottom: 40px;
}
.oc-progress {
/* Needs to be important to overwrite material-ui */
background-color: rgba(78, 133, 200, 0.8) !important;
height: 4px;
width: 100px;
}
.oc-progress > div {
/* Needs to be important to overwrite material-ui */
background-color: #4a76ac !important;
}
.oc-input {
border: 1px solid #20434f;
border-radius: 4px;
height: 45px;
width: 100%;
padding: 16px;
box-sizing: border-box;
font-size: 1rem;
}
.oc-label {
display: block;
margin-bottom: 12px;
font-weight: 700;
}
.oc-input.error {
outline: none;
border: 1px solid #fe4600;
}
.MuiTypography-colorError {
color: #fe4600 !important;
}
.oc-input:focus {
outline: 2px solid #e2baff;
border: 1px solid #fff;
}
.oc-input + .oc-input {
margin-top: 15px;
}
.MuiTouchRipple-root {
display: none !important;
}
.oc-card {
background: white;
display: inline-flex;
width: 500px;
}
.oc-card-body {
padding: 40px;
width: 100%;
}
.oc-button {
/* Needs to be important to overwrite material-ui */
font-size: 1.0625rem !important;
border-radius: 100px !important;
box-shadow: none !important;
font-weight: 700 !important;
}
.oc-button-primary {
/* Needs to be important to overwrite material-ui */
width: 100%;
background: #e2baff !important;
border-radius: 100px !important;
color: #20434f !important;
box-shadow: none !important;
font-weight: 700 !important;
}
.oc-button-secondary {
/* Needs to be important to overwrite material-ui */
width: 100%;
border-radius: 100px !important;
background: #F1F3F4 !important;
color: #20434f !important;
box-shadow: none !important;
}
.oc-footer-message {
color: white;
padding: 10px;
font-size: 0.8rem;
}
@media only screen and (max-width: 768px) {
.oc-logo {
height: 60px;
top: -90px;
}
.oc-login-bg-icon {
display: none;
}
.oc-card {
min-width: 50vw !important;
}
}
@media only screen and (max-width: 600px) {
.oc-card {
width: 100vw !important;
}
}
/* Helpers */
.oc-mt-l {
margin-top: 24px !important;
}
.oc-mb-m {
margin-bottom: 20px !important;
}
.oc-login-form div:not(:last-of-type) {
margin-bottom: 20px;
}
/*
* Special SR classes
* Used to hide an element visually, but keeping it accessible for accessibility tools.
*/
.oc-invisible-sr {
border: 0 !important;
clip: rect(1px, 1px, 1px, 1px) !important;
height: 1px !important;
overflow: hidden !important;
padding: 0 !important;
/* Need to make sure we override any existing styles. */
position: absolute !important;
top: 0;
white-space: nowrap;
width: 1px !important;
}
@@ -0,0 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
const ClientDisplayName = ({ client, ...rest }) => (
<span {...rest}>{client.display_name ? client.display_name : client.id}</span>
);
ClientDisplayName.propTypes = {
client: PropTypes.object.isRequired
};
export default ClientDisplayName;
@@ -0,0 +1,70 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import LinearProgress from '@material-ui/core/LinearProgress';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import { retryHello } from '../actions/common';
import { ErrorMessage } from '../errors';
class Loading extends React.PureComponent {
render() {
const { error, t } = this.props;
return (
<Grid item align="center">
{error === null && (
<LinearProgress className="oc-progress" />
)}
{error !== null && (
<div>
<Typography variant="h5" gutterBottom align="center">
{t("konnect.loading.error.headline", "Failed to connect to server")}
</Typography>
<Typography align="center" color="error">
<ErrorMessage error={error}></ErrorMessage>
</Typography>
<Button
autoFocus
color="primary"
variant="outlined"
className="oc-button-primary oc-mt-l"
onClick={(event) => this.retry(event)}
>
{t("konnect.login.retryButton.label", "Retry")}
</Button>
</div>
)}
</Grid>
);
}
retry(event) {
event.preventDefault();
this.props.dispatch(retryHello());
}
}
Loading.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
error: PropTypes.object,
dispatch: PropTypes.func.isRequired,
};
const mapStateToProps = (state) => {
const { error } = state.common;
return {
error
};
};
export default connect(mapStateToProps)(withTranslation()(Loading));
@@ -0,0 +1,76 @@
import React, { useCallback, useMemo, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import allLocales from '../locales';
function LocaleSelect({ locales: localesProp, ...other } = {}) {
const { i18n, ready } = useTranslation();
const handleChange = useCallback((event) => {
i18n.changeLanguage(event.target.value);
}, [ i18n ])
const locales = useMemo(() => {
if (!localesProp) {
return allLocales;
}
const supported = allLocales.filter(locale => {
return localesProp.includes(locale.locale);
});
return supported;
}, [localesProp]);
useEffect(() => {
if (locales) {
const found = locales.find((locale) => {
return locale.locale === i18n.language;
});
if (found) {
// Have language -> is supported all good.
return;
}
const wanted = i18n.modules.languageDetector.detectors.navigator.lookup();
i18n.modules.languageDetector.services.languageUtils.options.supportedLngs = locales.map(locale => locale.locale);
i18n.modules.languageDetector.services.languageUtils.options.fallbackLng = null;
let best = i18n.modules.languageDetector.services.languageUtils.getBestMatchFromCodes(wanted);
if (!best) {
best = locales[0].locale;
}
// Auto change language to best one found if the current selected one is not enabled.
if (i18n.language !== best) {
i18n.changeLanguage(best);
}
}
}, [i18n, locales]);
if (!ready || !locales || locales.length < 2) {
return null;
}
return <Select
value={i18n.language}
onChange={handleChange}
{...other}
>
{locales.map(language => {
return <MenuItem
key={language.locale}
value={language.locale}>
{language.nativeName}
</MenuItem>;
})}
</Select>;
}
LocaleSelect.propTypes = {
locales: PropTypes.arrayOf(PropTypes.string),
};
export default LocaleSelect;
@@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Route } from 'react-router-dom';
import RedirectWithQuery from './RedirectWithQuery';
const PrivateRoute = ({ component: Target, hello, ...rest }) => (
<Route {...rest} render={props => (
hello ? (
<Target {...props}/>
) : (
<RedirectWithQuery target='/identifier' />
)
)}/>
);
PrivateRoute.propTypes = {
component: PropTypes.elementType.isRequired,
hello: PropTypes.object
};
export default PrivateRoute;
@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router';
import { Redirect } from 'react-router-dom';
const RedirectWithQuery = ({target, location, ...rest}) => {
const to = {
pathname: target,
search: location.search,
hash: location.hash
};
return (
<Redirect to={to} {...rest}></Redirect>
);
};
RedirectWithQuery.propTypes = {
target: PropTypes.string.isRequired,
location: PropTypes.object.isRequired
};
export default withRouter(RedirectWithQuery);
@@ -0,0 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import Dialog from '@material-ui/core/Dialog';
import withMobileDialog from '@material-ui/core/withMobileDialog';
const ResponsiveDialog = (props) => {
return <Dialog {...props}/>;
};
ResponsiveDialog.propTypes = {
fullScreen: PropTypes.bool.isRequired
};
export default withMobileDialog()(ResponsiveDialog);
@@ -0,0 +1,114 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Trans } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import Grid from '@material-ui/core/Grid';
import DialogContent from '@material-ui/core/DialogContent';
import Loading from './Loading';
import { QsferaContext } from "../qsferaContext";
const styles = theme => ({
root: {
display: 'flex',
flex: 1,
zIndex: 999
},
content: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
},
actions: {
marginTop: -40,
justifyContent: 'flex-start',
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3)
},
wrapper: {
display: 'flex',
flex: 1,
alignItems: 'center'
}
});
const ResponsiveScreen = (props) => {
const {
classes,
withoutLogo,
withoutPadding,
loading,
children,
className,
branding,
...other
} = props;
const { theme } = useContext(QsferaContext);
const logo = (theme && !withoutLogo) ? (
<img src={'/' + theme.common?.logo} className="oc-logo" alt="КуСфера logo"/>
) : null;
const content = loading ? <Loading/> : (withoutPadding ? children : <DialogContent>{children}</DialogContent>);
return (
<Grid
container
justifyContent="center"
alignItems="center"
direction="column"
spacing={0}
className={[classes.root, className].filter(Boolean).join(' ')}
{...other}
>
<div className={classes.wrapper}>
<div className={classes.content}>
{branding?.signinPageLogoURI ? (
<a
href={branding.signinPageLogoURI}
target="_blank"
rel="noopener noreferrer"
className='oc-logo-container'
>
{logo}
</a>
) : (
<div className='oc-logo-container'>
{logo}
</div>
)}
<div className={"oc-card"}>
<div className={"oc-card-body"}>{content}</div>
</div>
</div>
</div>
<footer className="oc-footer-message">
<Trans i18nKey="konnect.footer.slogan">
<strong>КуСфера</strong>
</Trans>
</footer>
</Grid>
);
};
ResponsiveScreen.defaultProps = {
withoutLogo: false,
withoutPadding: false,
loading: false
};
ResponsiveScreen.propTypes = {
classes: PropTypes.object.isRequired,
withoutLogo: PropTypes.bool,
withoutPadding: PropTypes.bool,
loading: PropTypes.bool,
branding: PropTypes.object,
children: PropTypes.node.isRequired,
className: PropTypes.string,
PaperProps: PropTypes.object,
DialogProps: PropTypes.object
};
export default withStyles(styles)(ResponsiveScreen);
@@ -0,0 +1,90 @@
import React from 'react';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import { withStyles } from '@material-ui/core/styles';
import PropTypes from 'prop-types';
import Checkbox from '@material-ui/core/Checkbox';
import { useTranslation } from 'react-i18next';
const styles = () => ({
row: {
paddingTop: 0,
paddingBottom: 0
}
});
const ScopesList = ({scopes, meta, classes, ...rest}) => {
const { mapping, definitions } = meta;
const { t } = useTranslation();
const rows = [];
const known = {};
// TODO(longsleep): Sort scopes according to priority.
for (let scope in scopes) {
if (!scopes[scope]) {
continue;
}
let id = mapping[scope];
if (id) {
if (known[id]) {
continue;
}
known[id] = true;
} else {
id = scope;
}
let definition = definitions[id];
let label;
if (definition) {
switch (definition.id) {
case 'scope_alias_basic':
label = t("konnect.scopeDescription.aliasBasic", "Access your basic account information");
break;
case 'scope_offline_access':
label = t("konnect.scopeDescription.offlineAccess", "Keep the allowed access persistently and forever");
break;
default:
}
if (!label) {
label = definition.description;
}
}
if (!label) {
label = t("konnect.scopeDescription.scope", "Scope: {{scope}}", { scope });
}
rows.push(
<ListItem
disableGutters
dense
key={id}
className={classes.row}
><Checkbox
checked
disableRipple
disabled
/>
<ListItemText primary={label} />
</ListItem>
);
}
return (
<List {...rest}>
{rows}
</List>
);
};
ScopesList.propTypes = {
classes: PropTypes.object.isRequired,
scopes: PropTypes.object.isRequired,
meta: PropTypes.object.isRequired
};
export default withStyles(styles)(ScopesList);
@@ -0,0 +1,34 @@
import React from 'react';
import {
Fade,
CircularProgress,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles(() => ({
root: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
}));
const Spinner = () => {
const classes = useStyles();
return <div className={classes.root}>
<Fade
in
style={{
transitionDelay: '800ms',
}}
unmountOnExit
>
<CircularProgress size={70} thickness={1}/>
</Fade>
</div>;
}
export default Spinner;
@@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
const TextInput = (props) => {
const label = props.label;
const extraClassName = props.extraClassName;
delete props.label;
delete props.extraClassName;
return (
<div>
<label className="oc-label"
htmlFor={props.id}>{label}</label>
<input className={`oc-input ${extraClassName ? extraClassName : ''}`} {...props}
placeholder={props.placeholder ? props.placeholder : null}/>
</div>);
};
TextInput.propTypes = {
placeholder: PropTypes.object,
label: PropTypes.object,
id: PropTypes.string,
extraClassName: PropTypes.string,
}
export default TextInput;
@@ -0,0 +1,115 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import ResponsiveScreen from '../../components/ResponsiveScreen';
import { executeHello, executeLogoff } from '../../actions/common';
const styles = theme => ({
subHeader: {
marginBottom: theme.spacing(5)
},
wrapper: {
marginTop: theme.spacing(5),
position: 'relative',
display: 'inline-block'
}
});
class Goodbyescreen extends React.PureComponent {
componentDidMount() {
this.props.dispatch(executeHello());
}
render() {
const { classes, branding, hello, t } = this.props;
const loading = hello === null;
return (
<ResponsiveScreen loading={loading} branding={branding}>
{hello !== null && !hello.state && (
<div>
<Typography variant="h5" component="h3">
{t("konnect.goodbye.headline", "Goodbye")}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{t("konnect.goodbye.subHeader", "you have been signed out from your account")}
</Typography>
<Typography gutterBottom>
{t("konnect.goodbye.message.close", "You can close this window now.")}
</Typography>
</div>
)}
{hello !== null && hello.state === true && (
<div>
<Typography variant="h5" component="h3">
{t("konnect.goodbye.confirm.headline", "Hello {{displayName}}", { displayName: hello.displayName })}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{t("konnect.goodbye.confirm.subHeader", "please confirm sign out")}
</Typography>
<Typography gutterBottom>
{t("konnect.goodbye.message.confirm", "Press the button below, to sign out from your account now.")}
</Typography>
<DialogActions>
<div className={classes.wrapper}>
<Button
color="secondary"
variant="outlined"
className={classes.button}
onClick={(event) => this.logoff(event)}
>
{t("konnect.goodbye.signoutButton.label", "Sign out")}
</Button>
</div>
</DialogActions>
</div>
)}
</ResponsiveScreen>
);
}
logoff(event) {
event.preventDefault();
this.props.dispatch(executeLogoff()).then((response) => {
const { history } = this.props;
if (response.success) {
this.props.dispatch(executeHello());
history.push('/goodbye');
}
});
}
}
Goodbyescreen.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const { branding, hello } = state.common;
return {
branding,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Goodbyescreen)));
@@ -0,0 +1 @@
export { default } from './Goodbyescreen';
@@ -0,0 +1,150 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import Typography from '@material-ui/core/Typography';
import DialogContent from '@material-ui/core/DialogContent';
import { executeLogonIfFormValid, advanceLogonFlow } from '../../actions/login';
import { ErrorMessage } from '../../errors';
const styles = theme => ({
content: {
overflowY: 'visible',
},
subHeader: {
marginBottom: theme.spacing(2)
},
message: {
marginTop: theme.spacing(2)
},
accountList: {
marginLeft: theme.spacing(-5),
marginRight: theme.spacing(-5)
},
accountListItem: {
paddingLeft: theme.spacing(5),
paddingRight: theme.spacing(5)
}
});
class Chooseaccount extends React.PureComponent {
componentDidMount() {
const { hello, history } = this.props;
if ((!hello || !hello.state) && history.action !== 'PUSH') {
history.replace(`/identifier${history.location.search}${history.location.hash}`);
}
}
render() {
const { loading, errors, classes, hello, t } = this.props;
let errorMessage = null;
if (errors.http) {
errorMessage = <Typography color="error" className={classes.message}>
<ErrorMessage error={errors.http}></ErrorMessage>
</Typography>;
}
let username = '';
if (hello && hello.state) {
username = hello.username;
}
return (
<DialogContent className={classes.content}>
<Typography variant="h5" component="h3">
{t("konnect.chooseaccount.headline", "Choose an account")}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{t("konnect.chooseaccount.subHeader", "to sign in")}
</Typography>
<form action="" onSubmit={(event) => this.logon(event)}>
<List disablePadding className={classes.accountList}>
<ListItem
button
disableGutters
className={classes.accountListItem}
disabled={!!loading}
onClick={(event) => this.logon(event)}
><ListItemAvatar><Avatar>{username.substr(0, 1)}</Avatar></ListItemAvatar>
<ListItemText primary={username} />
</ListItem>
<ListItem
button
disableGutters
className={classes.accountListItem}
disabled={!!loading}
onClick={(event) => this.logoff(event)}
>
<ListItemAvatar>
<Avatar>
{t("konnect.chooseaccount.useOther.persona.label", "?")}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
t("konnect.chooseaccount.useOther.label", "Use another account")
}
/>
</ListItem>
</List>
{errorMessage}
</form>
</DialogContent>
);
}
logon(event) {
event.preventDefault();
const { hello, dispatch, history } = this.props;
dispatch(executeLogonIfFormValid(hello.username, '', true)).then((response) => {
if (response.success) {
dispatch(advanceLogonFlow(response.success, history));
}
});
}
logoff(event) {
event.preventDefault();
const { history} = this.props;
history.push(`/identifier${history.location.search}${history.location.hash}`);
}
}
Chooseaccount.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
loading: PropTypes.string.isRequired,
errors: PropTypes.object.isRequired,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const { loading, errors } = state.login;
const { hello } = state.common;
return {
loading,
errors,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Chooseaccount)));
@@ -0,0 +1,202 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {withTranslation, Trans} from 'react-i18next';
import {withStyles} from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import BaseTooltip from '@material-ui/core/Tooltip';
import CircularProgress from '@material-ui/core/CircularProgress';
import green from '@material-ui/core/colors/green';
import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import {executeConsent, advanceLogonFlow, receiveValidateLogon} from '../../actions/login';
import {ErrorMessage} from '../../errors';
import {REQUEST_CONSENT_ALLOW} from '../../actions/types';
import ClientDisplayName from '../../components/ClientDisplayName';
import ScopesList from '../../components/ScopesList';
const styles = theme => ({
button: {
margin: theme.spacing(1),
minWidth: 100
},
buttonProgress: {
color: green[500],
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12
},
header: {
margin: 0,
},
subHeader: {
marginBottom: theme.spacing(2)
},
scopesList: {
marginBottom: theme.spacing(2)
},
wrapper: {
marginTop: theme.spacing(2),
position: 'relative',
display: 'inline-block'
},
dialogActions: {
gap: theme.spacing(1)
},
message: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2)
}
});
const Tooltip = ({children, ...other} = {}) => {
// Ensures that there is only a single child for the tooltip element to
// make it compatible with the Trans component.
return <BaseTooltip {...other}><span>{children}</span></BaseTooltip>;
}
Tooltip.propTypes = {
children: PropTypes.node,
};
class Consent extends React.PureComponent {
componentDidMount() {
const {dispatch, hello, history, client} = this.props;
if ((!hello || !hello.state || !client) && history.action !== 'PUSH') {
history.replace(`/identifier${history.location.search}${history.location.hash}`);
}
dispatch(receiveValidateLogon({})); // XXX(longsleep): hack to reset loading and errors.
}
action = (allow = false, scopes = {}) => (event) => {
event.preventDefault();
if (allow === undefined) {
return;
}
// Convert all scopes which are true to a scope value.
const scope = Object.keys(scopes).filter(scope => {
return !!scopes[scope];
}).join(' ');
const {dispatch, history} = this.props;
dispatch(executeConsent(allow, scope)).then((response) => {
if (response.success) {
dispatch(advanceLogonFlow(response.success, history, true, {konnect: response.state}));
}
});
}
render() {
const {classes, loading, hello, errors, client, t} = this.props;
const scopes = hello.details.scopes || {};
const meta = hello.details.meta || {};
return (
<DialogContent>
<h1 className={classes.header}>
{t("konnect.consent.headline", "Hi {{displayName}}", {displayName: hello.displayName})}
</h1>
<Typography variant="subtitle1" className={classes.subHeader + " oc-mb-m"}>
{hello.username}
</Typography>
<Typography variant="subtitle1" gutterBottom>
<Trans t={t} i18nKey="konnect.consent.message">
<Tooltip
placement="bottom"
title={t("konnect.consent.tooltip.client", 'Clicking "Allow" will redirect you to: {{redirectURI}}', {redirectURI: client.redirect_uri})}
>
<span className={'oc-font-weight-light'}><ClientDisplayName client={client}/></span>
</Tooltip> wants to
</Trans>
</Typography>
<ScopesList dense disablePadding className={classes.scopesList} scopes={scopes}
meta={meta.scopes}></ScopesList>
<Typography variant="subtitle1" gutterBottom>
<Trans t={t} i18nKey="konnect.consent.question">
Allow <span className={'oc-font-weight-light'}><ClientDisplayName client={client}/></span> to do
this?
</Trans>
</Typography>
<Typography>
{t("konnect.consent.consequence", "By clicking Allow, you allow this app to use your information.")}
</Typography>
<form action="" onSubmit={this.action(undefined, scopes)}>
<DialogActions className={classes.dialogActions}>
<div className={classes.wrapper}>
<Button
color="secondary"
className={classes.button + ' oc-button-secondary'}
disabled={!!loading}
onClick={this.action(false, scopes)}
>
{t("konnect.consent.cancelButton.label", "Cancel")}
</Button>
{(loading && loading !== REQUEST_CONSENT_ALLOW) &&
<CircularProgress size={24} className={classes.buttonProgress}/>}
</div>
<div className={classes.wrapper}>
<Button
type="submit"
color="primary"
variant="contained"
className="oc-button-primary"
disabled={!!loading}
onClick={this.action(true, scopes)}
>
{t("konnect.consent.allowButton.label", "Allow")}
</Button>
{loading === REQUEST_CONSENT_ALLOW &&
<CircularProgress size={24} className={classes.buttonProgress}/>}
</div>
</DialogActions>
{errors.http && (
<Typography variant="subtitle2" color="error" className={classes.message}>
<ErrorMessage error={errors.http}></ErrorMessage>
</Typography>
)}
</form>
</DialogContent>
);
}
}
Consent.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
loading: PropTypes.string.isRequired,
errors: PropTypes.object.isRequired,
hello: PropTypes.object,
client: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const {hello} = state.common;
const {loading, errors} = state.login;
return {
loading: loading,
errors,
hello,
client: hello.details.client || {}
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Consent)));
@@ -0,0 +1,238 @@
import React, { useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { withStyles } from "@material-ui/core/styles";
import Button from "@material-ui/core/Button";
import CircularProgress from "@material-ui/core/CircularProgress";
import green from "@material-ui/core/colors/green";
import Typography from "@material-ui/core/Typography";
import Link from "@material-ui/core/Link";
import TextInput from "../../components/TextInput";
import {
updateInput,
executeLogonIfFormValid,
advanceLogonFlow,
} from "../../actions/login";
import { ErrorMessage } from "../../errors";
const styles = (theme) => ({
buttonProgress: {
color: green[500],
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
main: {
width: "100%",
},
header: {
textAlign: "center",
marginTop: 0,
marginBottom: theme.spacing(6),
},
wrapper: {
position: "relative",
width: "100%",
textAlign: "center",
},
message: {
marginTop: 5,
marginBottom: 5,
},
signinPageText: {
marginBottom: 12
}
});
function Login(props) {
const {
hello,
query,
dispatch,
history,
loading,
errors,
classes,
username,
password,
passwordResetLink,
branding,
} = props;
const { t } = useTranslation();
const loginFailed = errors.http;
const hasError = errors.http || errors.username || errors.password;
const errorMessage = errors.http ? (
<ErrorMessage error={errors.http}></ErrorMessage>
) : errors.username ? (
<ErrorMessage error={errors.username}></ErrorMessage>
) : (
<ErrorMessage error={errors.password}></ErrorMessage>
);
const extraPropsUsername = {
"aria-invalid": errors.username || errors.http ? "true" : "false",
};
const extraPropsPassword = {
"aria-invalid": errors.password || errors.http ? "true" : "false",
};
if (errors.username || errors.http) {
extraPropsUsername["extraClassName"] = "error";
extraPropsUsername["aria-describedby"] = "oc-login-error-message";
}
if (errors.password || errors.http) {
extraPropsPassword["extraClassName"] = "error";
extraPropsPassword["aria-describedby"] = "oc-login-error-message";
}
useEffect(() => {
if (hello && hello.state && history.action !== "PUSH") {
if (!query.prompt || query.prompt.indexOf("select_account") === -1) {
dispatch(advanceLogonFlow(true, history));
return;
}
history.replace(
`/chooseaccount${history.location.search}${history.location.hash}`
);
return;
}
});
const handleChange = (name) => (event) => {
dispatch(updateInput(name, event.target.value));
};
const handleNextClick = (event) => {
event.preventDefault();
dispatch(executeLogonIfFormValid(username, password, false)).then(
(response) => {
if (response.success) {
dispatch(advanceLogonFlow(response.success, history));
}
}
);
};
return (
<div className={classes.main}>
<h1 className={classes.header}>
{" "}
{t("konnect.login.headline", "Sign in")}
</h1>
{branding?.signinPageText && (
<Typography
className={classes.signinPageText}
variant="body2"
dangerouslySetInnerHTML={{ __html: branding.signinPageText }}
/>
)}
<form
action=""
className="oc-login-form"
onSubmit={(event) => handleNextClick(event)}
>
<TextInput
autoFocus
autoCapitalize="off"
spellCheck="false"
value={username}
onChange={handleChange("username")}
autoComplete="username"
placeholder={t("konnect.login.usernameField.label", "Username")}
label={t("konnect.login.usernameField.label", "Username")}
id="oc-login-username"
{...extraPropsUsername}
/>
<TextInput
type="password"
margin="normal"
onChange={handleChange("password")}
autoComplete="current-password"
placeholder={t("konnect.login.passwordField.label", "Password")}
label={t("konnect.login.passwordField.label", "Password")}
id="oc-login-password"
{...extraPropsPassword}
/>
{hasError && (
<Typography
id="oc-login-error-message"
variant="subtitle2"
component="span"
color="error"
className={classes.message}
>
{errorMessage}
</Typography>
)}
<div className={classes.wrapper}>
{loginFailed && passwordResetLink && (
<Link
id="oc-login-password-reset"
href={passwordResetLink}
variant="subtitle2"
>
{"Reset password?"}
</Link>
)}
<Button
type="submit"
color="primary"
variant="contained"
className="oc-button-primary oc-mt-l"
disabled={!!loading}
onClick={handleNextClick}
>
{t("konnect.login.nextButton.label", "Log in")}
</Button>
{loading && (
<CircularProgress size={24} className={classes.buttonProgress} />
)}
</div>
</form>
</div>
);
}
Login.propTypes = {
classes: PropTypes.object.isRequired,
loading: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
passwordResetLink: PropTypes.string.isRequired,
errors: PropTypes.object.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
query: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
const mapStateToProps = (state) => {
const { loading, username, password, errors } = state.login;
const { branding, hello, query, passwordResetLink } = state.common;
return {
loading,
username,
password,
errors,
branding,
hello,
query,
passwordResetLink,
};
};
export default connect(mapStateToProps)(withStyles(styles)(Login));
@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Route, Switch } from 'react-router-dom';
import { withStyles } from '@material-ui/core/styles';
import ResponsiveScreen from '../../components/ResponsiveScreen';
import RedirectWithQuery from '../../components/RedirectWithQuery';
import { executeHello } from '../../actions/common';
import Login from './Login';
import Chooseaccount from './Chooseaccount';
import Consent from './Consent';
const styles = () => ({
});
class Loginscreen extends React.PureComponent {
componentDidMount() {
this.props.dispatch(executeHello());
}
render() {
const { branding, hello } = this.props;
const loading = hello === null;
return (
<ResponsiveScreen loading={loading} branding={branding} withoutPadding>
<Switch>
<Route path="/identifier" exact component={Login}></Route>
<Route path="/chooseaccount" exact component={Chooseaccount}></Route>
<Route path="/consent" exact component={Consent}></Route>
<RedirectWithQuery target="/identifier"/>
</Switch>
</ResponsiveScreen>
);
}
}
Loginscreen.propTypes = {
classes: PropTypes.object.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired
};
const mapStateToProps = (state) => {
const { branding, hello } = state.common;
return {
branding,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(Loginscreen));
@@ -0,0 +1 @@
export { default } from './Loginscreen';
@@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import ResponsiveScreen from '../../components/ResponsiveScreen';
import { executeLogoff } from '../../actions/common';
const styles = theme => ({
button: {
margin: theme.spacing(1),
minWidth: 100
},
subHeader: {
marginBottom: theme.spacing(5)
}
});
class Welcomescreen extends React.PureComponent {
render() {
const { classes, branding, hello, t } = this.props;
const loading = hello === null;
return (
<ResponsiveScreen loading={loading} branding={branding}>
<Typography variant="h5" component="h3">
{t("konnect.welcome.headline", "Welcome {{displayName}}", {displayName: hello.displayName})}
</Typography>
<Typography variant="subtitle1" className={classes.subHeader}>
{hello.username}
</Typography>
<Typography gutterBottom>
{t("konnect.welcome.message", "You are signed in - awesome!")}
</Typography>
<DialogActions>
<Button
color="secondary"
className={classes.button}
variant="contained"
onClick={(event) => this.logoff(event)}
>
{t("konnect.welcome.signoutButton.label", "Sign out")}
</Button>
</DialogActions>
</ResponsiveScreen>
);
}
logoff(event) {
event.preventDefault();
this.props.dispatch(executeLogoff()).then((response) => {
const { history } = this.props;
if (response.success) {
history.push('/identifier');
}
});
}
}
Welcomescreen.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
branding: PropTypes.object,
hello: PropTypes.object,
dispatch: PropTypes.func.isRequired,
history: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
const { branding, hello } = state.common;
return {
branding,
hello
};
};
export default connect(mapStateToProps)(withStyles(styles)(withTranslation()(Welcomescreen)));
@@ -0,0 +1 @@
export { default } from './Welcomescreen';
+67
View File
@@ -0,0 +1,67 @@
import { withTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
export const ERROR_LOGIN_VALIDATE_MISSINGUSERNAME = 'konnect.error.login.validate.missingUsername';
export const ERROR_LOGIN_VALIDATE_MISSINGPASSWORD = 'konnect.error.login.validate.missingPassword';
export const ERROR_LOGIN_FAILED = 'konnect.error.login.failed';
export const ERROR_HTTP_NETWORK_ERROR = 'konnect.error.http.networkError';
export const ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS = 'konnect.error.http.unexpectedResponseStatus';
export const ERROR_HTTP_UNEXPECTED_RESPONSE_STATE = 'konnect.error.http.unexpectedResponseState';
// Error with values.
export class ExtendedError extends Error {
values = undefined;
constructor(message, values) {
super(message);
if (Error.captureStackTrace !== undefined) {
Error.captureStackTrace(this, ExtendedError);
}
this.values = values;
}
}
// Component to translate error text with values.
function ErrorMessageComponent(props) {
const { error, t, values } = props;
if (!error) {
return null;
}
const id = error.id ? error.id : error.message;
const messageDescriptor = Object.assign({}, {
id,
defaultMessage: error.id ? error.message : undefined,
values: {
...error.values,
...values,
},
});
switch (messageDescriptor.id) {
case ERROR_LOGIN_VALIDATE_MISSINGUSERNAME:
return t("konnect.error.login.validate.missingUsername", "Enter a valid value.", messageDescriptor.values);
case ERROR_LOGIN_VALIDATE_MISSINGPASSWORD:
return t("konnect.error.login.validate.missingPassword", "Enter your password.");
case ERROR_LOGIN_FAILED:
return t("konnect.error.login.failed", "Logon failed. Please verify your credentials and try again.");
case ERROR_HTTP_NETWORK_ERROR:
return t("konnect.error.http.networkError", "Network error. Please check your connection and try again.");
case ERROR_HTTP_UNEXPECTED_RESPONSE_STATUS:
return t("konnect.error.http.unexpectedResponseStatus", "Unexpected HTTP response: {{status}}. Please check your connection and try again.", messageDescriptor.values);
case ERROR_HTTP_UNEXPECTED_RESPONSE_STATE:
return t("konnect.error.http.unexpectedResponseState", "Unexpected response state: {{state}}", messageDescriptor.values);
default:
}
const f = t;
return f(messageDescriptor.defaultMessage, messageDescriptor.values);
}
ErrorMessageComponent.propTypes = {
error: PropTypes.object,
t: PropTypes.func,
values: PropTypes.any,
};
export const ErrorMessage = withTranslation()(ErrorMessageComponent);
@@ -0,0 +1,17 @@
/* not so fancy background */
#bg {
background-color: white;
}
#bg > div {
position: fixed;
top: 0;
width: 100%;
height: 100%;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-image: url(./images/background.png);
z-index: 0;
}
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import LanguageDetector, { CustomDetector } from 'i18next-browser-languagedetector';
import queryString from 'query-string';
import locales from './locales';
const config = {
uiLocalesQueryName: 'ui_locales', // Same as OIDC uses.
uiLocaleCookieName: 'ui_locale', // For domain wide syncing, not set here.
uiLocaleLocalStorageName: 'lico.identifier_ui_locale', // Sufficiently unique, set here.
}
const supportedLanguages = ['en-GB', 'en', ...locales.map((locale) => {
return locale.locale;
})];
const queryUiLocalesDetector: CustomDetector = {
name: 'queryUiLocales',
lookup: (options): string | string[] | undefined => {
const query = queryString.parse(document.location.search);
const ui_locales = query[config.uiLocalesQueryName];
if (!ui_locales) {
return;
}
if (Array.isArray(ui_locales)) {
return ui_locales as string[];
} else {
return ui_locales.split(' ');
}
},
}
const languageDetector = new LanguageDetector();
languageDetector.addDetector(queryUiLocalesDetector);
i18n
.use(resourcesToBackend((language, namespace, callback) => {
import(
/* webpackMode: "lazy-once" */
/* webpackInclude: /\.json$/ */
/* webpackChunkName: "all-i18n-data" */
/* webpackPrefetch: true */
`./locales/${language}/${namespace}.json`
)
.then((resources) => {
callback(null, resources)
})
.catch((error) => {
callback(error, null)
})
}))
.use(languageDetector)
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: 'en-GB',
supportedLngs: [...supportedLanguages],
cleanCode: true,
returnEmptyString: false,
debug: false,
detection: {
/* https://github.com/i18next/i18next-browser-languageDetector */
order: [queryUiLocalesDetector.name, 'cookie', 'localStorage', 'navigator'],
lookupCookie: config.uiLocaleCookieName,
lookupLocalStorage: config.uiLocaleLocalStorageName,
caches: ['localStorage'],
},
interpolation: {
escapeValue: false,
},
});
export default i18n;
@@ -0,0 +1,2 @@
app-icon.svg
app-icon-*.png
+42
View File
@@ -0,0 +1,42 @@
# Tools
CONVERT ?= convert
IDENTIFY ?= identify
BASE64 ?= base64
ENVSUBST ?= envsubst
SCOUR ?= scour
INKSCAPE ?= inkscape
# Variables
STATIC ?= ../../public/static
ICON ?= lico-icon.svg
# Build
all: app-icon.svg
.PHONY: icons
icons: $(STATIC)/favicon.ico
.PHONY: $(STATIC)/favicon.ico
$(STATIC)/favicon.ico: app-icon-rounded-256x256.png
$(CONVERT) -background transparent $< -define icon:auto-resize=16,32,48,64,128,256 $@
app-icon.svg: $(ICON)
cp -vaf $< $@
app-icon-whitebox-256x256.png: app-icon.svg
$(INKSCAPE) -z -e $@.tmp -w 204.8 -h 204.8 -b white -y 1.0 $<
$(CONVERT) $@.tmp -background white -gravity center -extent 256x256 $@
@$(RM) $@.tmp
app-icon-rounded-256x256.png: app-icon-whitebox-256x256.png
$(CONVERT) -size 256x256 xc:none -draw "roundrectangle 2,2,252,252,126,126" $@.tmp.png
$(CONVERT) $< -matte $@.tmp.png -compose DstIn -composite $@
@$(RM) $@.tmp.png
.PHONY: clean
clean:
$(RM) app-icon-*.png || true
$(RM) app-icon.svg || true
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 180 180" role="img" aria-label="КуСфера">
<path fill="#061D2B" d="M34,12h112C158,12 168,22 168,34v112C168,158 158,168 146,168H34C22,168 12,158 12,146V34C12,22 22,12 34,12z"/>
<path fill="#123E55" fill-opacity="0.74" d="M12,45C48,19 87,20 121,48C145,68 158,68 168,60V34C168,22 158,12 146,12H34C22,12 12,22 12,34z"/>
<path fill="#19B8C9" fill-opacity="0.18" d="M12,126C46,105 82,104 114,124C139,139 156,139 168,128V146C168,158 158,168 146,168H34C22,168 12,158 12,146z"/>
<path fill="none" stroke="#20D6E6" stroke-linecap="round" stroke-width="10" d="M75,47C100,29 138,39 153,70C167,100 151,135 121,147"/>
<path fill="none" stroke="#9AF8FF" stroke-linecap="round" stroke-width="4" d="M81,45C101,35 130,39 146,62"/>
<path fill="#21D1D6" d="M48,52h25v50h-25z"/>
<path fill="#F8FCFF" d="M48,102h25v42h-25z"/>
<path fill="#18BFD0" d="M77,94L111,55H143L99,103z"/>
<path fill="#F8FCFF" d="M77,104L100,82L148,144H116z"/>
<path fill="#9DF8FF" fill-opacity="0.45" d="M48,52h25v8h-25z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="КуСфера">
<path fill="#061D2B" d="M34,12h112C158,12 168,22 168,34v112C168,158 158,168 146,168H34C22,168 12,158 12,146V34C12,22 22,12 34,12z"/>
<path fill="#123E55" fill-opacity="0.74" d="M12,45C48,19 87,20 121,48C145,68 158,68 168,60V34C168,22 158,12 146,12H34C22,12 12,22 12,34z"/>
<path fill="#19B8C9" fill-opacity="0.18" d="M12,126C46,105 82,104 114,124C139,139 156,139 168,128V146C168,158 158,168 146,168H34C22,168 12,158 12,146z"/>
<path fill="none" stroke="#20D6E6" stroke-linecap="round" stroke-width="10" d="M75,47C100,29 138,39 153,70C167,100 151,135 121,147"/>
<path fill="none" stroke="#9AF8FF" stroke-linecap="round" stroke-width="4" d="M81,45C101,35 130,39 146,62"/>
<path fill="#21D1D6" d="M48,52h25v50h-25z"/>
<path fill="#F8FCFF" d="M48,102h25v42h-25z"/>
<path fill="#18BFD0" d="M77,94L111,55H143L99,103z"/>
<path fill="#F8FCFF" d="M77,104L100,82L148,144H116z"/>
<path fill="#9DF8FF" fill-opacity="0.45" d="M48,52h25v8h-25z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="КуСфера">
<path fill="#061D2B" d="M34,12h112C158,12 168,22 168,34v112C168,158 158,168 146,168H34C22,168 12,158 12,146V34C12,22 22,12 34,12z"/>
<path fill="#123E55" fill-opacity="0.74" d="M12,45C48,19 87,20 121,48C145,68 158,68 168,60V34C168,22 158,12 146,12H34C22,12 12,22 12,34z"/>
<path fill="#19B8C9" fill-opacity="0.18" d="M12,126C46,105 82,104 114,124C139,139 156,139 168,128V146C168,158 158,168 146,168H34C22,168 12,158 12,146z"/>
<path fill="none" stroke="#20D6E6" stroke-linecap="round" stroke-width="10" d="M75,47C100,29 138,39 153,70C167,100 151,135 121,147"/>
<path fill="none" stroke="#9AF8FF" stroke-linecap="round" stroke-width="4" d="M81,45C101,35 130,39 146,62"/>
<path fill="#21D1D6" d="M48,52h25v50h-25z"/>
<path fill="#F8FCFF" d="M48,102h25v42h-25z"/>
<path fill="#18BFD0" d="M77,94L111,55H143L99,103z"/>
<path fill="#F8FCFF" d="M77,104L100,82L148,144H116z"/>
<path fill="#9DF8FF" fill-opacity="0.45" d="M48,52h25v8h-25z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import './i18n';
import App from './App';
import store from './store';
import './app.css';
const root = document.getElementById('root')
// if a custom background image has been configured, make use of it
const bgImg = root.getAttribute('data-bg-img')
ReactDOM.render(
<React.StrictMode>
<Provider store={store as any}>
<App bgImg={bgImg}/>
</Provider>
</React.StrictMode>,
root
);
@@ -0,0 +1,95 @@
{
"konnect": {
"consent": {
"message": "<0><0><0></0></0></0> möchte",
"allowButton": {
"label": "Einverstanden"
},
"question": "<1><0></0></1> den Zugriff gestatten?",
"consequence": "Wenn Sie Einverstanden klicken, erhält die App Zugriff auf Ihre Informationen.",
"cancelButton": {
"label": "Abbrechen"
},
"tooltip": {
"client": "Wenn Sie \"Einverstanden\" klicken werden Sie zu {{redirectURI}} weitergeleitet"
},
"headline": "Hallo {{displayName}}"
},
"chooseaccount": {
"useOther": {
"persona": {
"label": "?"
},
"label": "Anderes Konto"
},
"headline": "Konto auswählen",
"subHeader": "das angemeldet werden soll"
},
"scopeDescription": {
"aliasBasic": "Zugriff auf Ihre grundlegenden Benutzerinformationen",
"offlineAccess": "Dauerhaften Zugriff (läuft nicht ab)",
"scope": "Geltungsbereich: {{scope}}"
},
"login": {
"usernameField": {
"label": "Benutzername",
"placeholder": {
"email": "E-Mail",
"identity": "Identität",
"username": "Benutzername"
}
},
"nextButton": {
"label": "Weiter"
},
"passwordField": {
"label": "Passwort"
},
"retryButton": {
"label": "Wiederholen"
},
"headline": "Anmelden"
},
"error": {
"login": {
"validate": {
"missingUsername": "Geben Sie einen gültigen Wert ein.",
"missingPassword": "Geben Sie ein Passwort ein."
},
"failed": "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingabe und versuchen Sie es noch einmal."
},
"http": {
"networkError": "Netzwerkfehler. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es noch einmal.",
"unexpectedResponseStatus": "Unerwartete HTTP-Antwort: {{status}}. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es noch einmal.",
"unexpectedResponseState": "Unerwarteter Antwort-Status: {{state}}"
}
},
"loading": {
"error": {
"headline": "Verbindung zum Server fehlgeschlagen"
}
},
"goodbye": {
"headline": "Bis bald",
"confirm": {
"headline": "Hallo {{displayName}}",
"subHeader": "Bitte bestätigen Sie, dass Sie sich abmelden möchten"
},
"message": {
"confirm": "Klicken Sie auf die Schaltfläche unten um sich aus Ihrem Konto abzumelden.",
"close": "Sie können dieses Fenster jetzt schließen."
},
"signoutButton": {
"label": "Abmelden"
},
"subHeader": "sie sind jetzt abgemeldet"
},
"welcome": {
"signoutButton": {
"label": "Abmelden"
},
"headline": "Willkommen {{displayName}}",
"message": "Sie sind angemeldet - super!"
}
}
}
@@ -0,0 +1,40 @@
{
"konnect": {
"consent": {
"allowButton": {},
"cancelButton": {},
"tooltip": {}
},
"chooseaccount": {
"useOther": {
"persona": {}
}
},
"scopeDescription": {},
"login": {
"usernameField": {
"placeholder": {}
},
"nextButton": {},
"passwordField": {},
"retryButton": {}
},
"error": {
"login": {
"validate": {}
},
"http": {}
},
"loading": {
"error": {}
},
"goodbye": {
"confirm": {},
"message": {},
"signoutButton": {}
},
"welcome": {
"signoutButton": {}
}
}
}
@@ -0,0 +1,91 @@
{
"konnect": {
"consent": {
"message": "<0><0><0></0></0></0> souhaite",
"allowButton": {
"label": "Autoriser"
},
"question": "Autoriser <1><0></0></1> à faire cela?",
"consequence": "En cliquant, vous autoriser l'app à accéder à vos informations.",
"cancelButton": {
"label": "Annuler"
},
"tooltip": {
"client": "En cliquant \"Autoriser\" vous serez redirigé vers: {{redirectURI}}"
},
"headline": "Bonjour {{displayName}}"
},
"chooseaccount": {
"useOther": {
"persona": {
"label": "?"
},
"label": "Utiliser un autre compte"
},
"headline": "Choisir un compte",
"subHeader": "identification"
},
"scopeDescription": {
"aliasBasic": "Consulter les informations de base de votre compte",
"offlineAccess": "Conserver les autorisations d'accès à l'avenir",
"scope": "Portée : {{scope}}"
},
"login": {
"usernameField": {
"placeholder": {
"username": "Utilisateur"
}
},
"nextButton": {
"label": "Suivant"
},
"passwordField": {
"label": "Mot de passe"
},
"retryButton": {
"label": "Réessayer"
},
"headline": "Identification"
},
"error": {
"login": {
"validate": {
"missingPassword": "Saisir un mot de passe."
},
"failed": "Echec de connexion. Vérifier vos identifiants et essayer à nouveau."
},
"http": {
"networkError": "Erreur réseau. Vérifier votre connexion, et réessayer.",
"unexpectedResponseStatus": "Erreur HTTP inattendue : {{status}}. Vérifier votre connexion et réessayer.",
"unexpectedResponseState": "Erreur d'état inattendue : {{state}}"
}
},
"loading": {
"error": {
"headline": "La connexion au serveur a échoué"
}
},
"goodbye": {
"headline": "Au revoir",
"confirm": {
"headline": "Bonjour {{displayName}}",
"subHeader": "confirmer votre déconnexion"
},
"message": {
"confirm": "Cliquer le bouton ci-dessous, pour quitter.",
"close": "Vous pouvez fermer cette fenêtre à présent."
},
"signoutButton": {
"label": "Quitter"
},
"subHeader": "vous avez été déconnecté"
},
"welcome": {
"signoutButton": {
"label": "Quitter"
},
"headline": "Bienvenue {{displayName}}",
"message": "Magnifique - Vous êtes connecté!"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import allLocales from './locales.json';
interface Locale {
locale: string,
name: string,
nativeName: string,
}
function enableLocales(locales: Locale[]): Locale[] {
return locales;
}
export const locales = enableLocales(allLocales);
export default locales;
@@ -0,0 +1,40 @@
{
"konnect": {
"consent": {
"allowButton": {},
"cancelButton": {},
"tooltip": {}
},
"chooseaccount": {
"useOther": {
"persona": {}
}
},
"scopeDescription": {},
"login": {
"usernameField": {
"placeholder": {}
},
"nextButton": {},
"passwordField": {},
"retryButton": {}
},
"error": {
"login": {
"validate": {}
},
"http": {}
},
"loading": {
"error": {}
},
"goodbye": {
"confirm": {},
"message": {},
"signoutButton": {}
},
"welcome": {
"signoutButton": {}
}
}
}
@@ -0,0 +1,66 @@
[
{
"locale": "de",
"name": "German",
"nativeName": "Deutsch",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "en-GB",
"name": "English",
"nativeName": "English",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "es",
"name": "Spanish",
"nativeName": "Español",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "fr",
"name": "French",
"nativeName": "Français",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "it",
"name": "Italian",
"nativeName": "Italiano",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "nl",
"name": "Dutch",
"nativeName": "Nederlands",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
}
},
{
"locale": "zh-CN",
"name": "Simplified Chinese",
"nativeName": "简体中文",
"orientation": {
"characterOrder": "left-to-right",
"lineOrder": "top-to-bottom"
},
"ietf": "zh_hans"
}
]
@@ -0,0 +1,91 @@
{
"konnect": {
"consent": {
"message": "<0><0><0></0></0></0> wil",
"allowButton": {
"label": "Toestaan"
},
"question": "<1><0></0></1> toestaan dit te doen?",
"consequence": "Door op Toestaan te klikken, krijgt deze app toestemming je informatie te gebruiken.",
"cancelButton": {
"label": "Annuleren"
},
"tooltip": {
"client": "Door op \"Toestaan\" te klikken word je doorverwezen naar: {{redirectURI}}"
},
"headline": "Hoi {{displayName}}"
},
"chooseaccount": {
"useOther": {
"persona": {
"label": "?"
},
"label": "Gebruik een ander account"
},
"headline": "Account kiezen",
"subHeader": "aanmelden"
},
"scopeDescription": {
"aliasBasic": "Basis accountgegevens weergeven",
"offlineAccess": "De toestemming voor altijd onthouden",
"scope": "Scope: {{scope}}"
},
"login": {
"usernameField": {
"placeholder": {
"username": "Gebruikersnaam"
}
},
"nextButton": {
"label": "Volgende"
},
"passwordField": {
"label": "Wachtwoord"
},
"retryButton": {
"label": "Opnieuw"
},
"headline": "Aanmelden"
},
"error": {
"login": {
"validate": {
"missingPassword": "Voer een wachtwoord in."
},
"failed": "Inloggen mislukt. Controleer logingegevens en probeer opnieuw."
},
"http": {
"networkError": "Netwerk probleem. Controleer je verbinding en probeer opnieuw.",
"unexpectedResponseStatus": "Onverwachte HTTP respons: {{status}}. Controleer je verbinding en probeer opnieuw.",
"unexpectedResponseState": "Onverwachte respons status: {{state}}"
}
},
"loading": {
"error": {
"headline": "Kon niet met server verbinden"
}
},
"goodbye": {
"headline": "Tot ziens",
"confirm": {
"headline": "Hallo {{displayName}}",
"subHeader": "bevestig afmelden"
},
"message": {
"confirm": "Klik op onderstaande knop om af te melden van je account.",
"close": "Dit venster kan nu worden gesloten."
},
"signoutButton": {
"label": "Afmelden"
},
"subHeader": "je bent afgemeld van je account"
},
"welcome": {
"signoutButton": {
"label": "Afmelden"
},
"headline": "Welkom {{displayName}}",
"message": "Je bent aangemeld - fantastisch!"
}
}
}
@@ -0,0 +1,40 @@
{
"konnect": {
"consent": {
"allowButton": {},
"cancelButton": {},
"tooltip": {}
},
"chooseaccount": {
"useOther": {
"persona": {}
}
},
"scopeDescription": {},
"login": {
"usernameField": {
"placeholder": {}
},
"nextButton": {},
"passwordField": {},
"retryButton": {}
},
"error": {
"login": {
"validate": {}
},
"http": {}
},
"loading": {
"error": {}
},
"goodbye": {
"confirm": {},
"message": {},
"signoutButton": {}
},
"welcome": {
"signoutButton": {}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
export function newHelloRequest(flow, query) {
const r = {};
if (query.prompt) {
// TODO(longsleep): Validate prompt values?
r.prompt = query.prompt;
}
let selectedFlow = flow;
switch (flow) {
case 'oauth':
case 'consent':
case 'oidc':
r.scope = query.scope || '';
r.client_id = query.client_id || ''; // eslint-disable-line camelcase
r.redirect_uri = query.redirect_uri || ''; // eslint-disable-line camelcase
if (query.id_token_hint) {
r.id_token_hint = query.id_token_hint; // eslint-disable-line camelcase
}
if (query.max_age) {
r.max_age = query.max_age; // eslint-disable-line camelcase
}
if (query.claims_scope) {
// Add additional scopes from claims request if given.
r.scope += ' ' + query.claims_scope;
}
break;
default:
selectedFlow = null;
}
if (selectedFlow) {
r.flow = selectedFlow;
}
return r;
}
+6
View File
@@ -0,0 +1,6 @@
import { createContext } from 'react';
export const QsferaContext = createContext({
theme: null,
config: null,
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
@@ -0,0 +1,78 @@
import {
RECEIVE_ERROR,
RESET_HELLO,
RECEIVE_HELLO,
SERVICE_WORKER_NEW_CONTENT
} from '../actions/types';
import queryString from 'query-string';
const query = queryString.parse(document.location.search);
const flow = query.flow || '';
delete query.flow;
const defaultPathPrefix = (() => {
const root = document.getElementById('root');
let pathPrefix = root ? root.getAttribute('data-path-prefix') : null;
if (!pathPrefix || pathPrefix === '__PATH_PREFIX__') {
// Not replaced, probably we are running in debug mode or whatever. Use sane default.
pathPrefix = '/signin/v1';
}
return pathPrefix;
})();
const defaultPasswordResetLink = (() => {
const root = document.getElementById('root');
let link = root ? root.getAttribute('passwort-reset-link') : null;
if (!link || link === '__PASSWORD_RESET_LINK__') {
// Not replaced, probably we are running in debug mode or whatever. Use sane default.
link = '';
}
return link;
})();
const defaultState = {
hello: null,
branding: null,
error: null,
flow: flow,
query: query,
updateAvailable: false,
pathPrefix: defaultPathPrefix,
passwordResetLink: defaultPasswordResetLink
};
function commonReducer(state = defaultState, action) {
switch (action.type) {
case RECEIVE_ERROR:
return Object.assign({}, state, {
error: action.error
});
case RESET_HELLO:
return Object.assign({}, state, {
hello: null,
branding: null
});
case RECEIVE_HELLO:
return Object.assign({}, state, {
hello: {
state: action.state,
username: action.username,
displayName: action.displayName,
details: action.hello
},
branding: action.hello.branding ? action.hello.branding : state.branding
});
case SERVICE_WORKER_NEW_CONTENT:
return Object.assign({}, state, {
updateAvailable: true
});
default:
return state;
}
}
export default commonReducer;

Some files were not shown because too many files have changed in this diff Show More