Repository: owncloud/ocis
Pull request #9766 (2024-08-09 06:43:20)

chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.22.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.21.0 to 0.22.0.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/golang/oauth2/commit/6d8340f1c53c54333dc5f280d68732afd4d05113"><code>6d8340f</code></a> LICENSE: update per Google Legal</li>
<li>See full diff in <a href="https://github.com/golang/oauth2/compare/v0.21.0...v0.22.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/oauth2&package-manager=go_modules&previous-version=0.21.0&new-version=0.22.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.drone.star b/.drone.star
index 1438080856a..2c9885d2b3c 100644
--- a/.drone.star
+++ b/.drone.star
@@ -2988,7 +2988,7 @@ def k6LoadTests(ctx):
                 "name": "open-grafana-dashboard",
                 "image": OC_CI_ALPINE,
                 "commands": [
-                    "echo 'Grafana Dashboard: https://grafana.k6.infra.owncloud.works/d/P4D1D31A5B69203FF'",
+                    "echo 'Grafana Dashboard: https://grafana.k6.infra.owncloud.works'",
                 ],
             },
         ],
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f33ce3bed90..8b23546368d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,6 +56,7 @@ The following sections list the changes for unreleased.
 * Bugfix - Use bool type for web embed delegatedAuthentication: [#9692](https://github.com/owncloud/ocis/pull/9692)
 * Bugfix - Repair nats-js-kv registry: [#9734](https://github.com/owncloud/ocis/pull/9734)
 * Bugfix - Use less selectors that watch the registry: [#9741](https://github.com/owncloud/ocis/pull/9741)
+* Bugfix - We fixed the client config generation for the built in IDP: [#9770](https://github.com/owncloud/ocis/pull/9770)
 * Enhancement - New WOPI operations added to the collaboration service: [#9505](https://github.com/owncloud/ocis/pull/9505)
 * Enhancement - Allow configuring grpc max connection age: [#9657](https://github.com/owncloud/ocis/pull/9657)
 * Enhancement - Local WEB App configuration: [#9691](https://github.com/owncloud/ocis/pull/9691)
@@ -63,6 +64,8 @@ The following sections list the changes for unreleased.
 * Enhancement - Bump tusd pkg to v2: [#9714](https://github.com/owncloud/ocis/pull/9714)
 * Enhancement - Gateways should directly talk to themselves: [#9714](https://github.com/owncloud/ocis/pull/9714)
 * Enhancement - Support Skyhigh Security ICAP as an ICAP server: [#9720](https://github.com/owncloud/ocis/issues/9720)
+* Enhancement - Added generic way to translate composite entities: [#9722](https://github.com/owncloud/ocis/pull/9722)
+* Enhancement - Add an API to auth-app service: [#9755](https://github.com/owncloud/ocis/pull/9755)
 * Enhancement - Bump go-micro plugins pkg: [#9756](https://github.com/owncloud/ocis/pull/9756)
 
 ## Details
@@ -97,6 +100,14 @@ The following sections list the changes for unreleased.
 
    https://github.com/owncloud/ocis/pull/9741
 
+* Bugfix - We fixed the client config generation for the built in IDP: [#9770](https://github.com/owncloud/ocis/pull/9770)
+
+   We now use the OCIS_URL to generate the web client registration configuration.
+   It does not make sense use the OCIS_ISSUER_URL if the idp was configured to run
+   on a different domain.
+
+   https://github.com/owncloud/ocis/pull/9770
+
 * Enhancement - New WOPI operations added to the collaboration service: [#9505](https://github.com/owncloud/ocis/pull/9505)
 
    PutRelativeFile, DeleteFile and RenameFile operations have been added to the
@@ -157,6 +168,23 @@ The following sections list the changes for unreleased.
    https://github.com/owncloud/ocis/issues/9720
    https://github.com/fschade/icap-client/pull/6
 
+* Enhancement - Added generic way to translate composite entities: [#9722](https://github.com/owncloud/ocis/pull/9722)
+
+   Added a generic way to translate the necessary fields in composite entities. The
+   function takes the entity, translation function and fields to translate that are
+   described by the TranslateField function. The function supports nested structs
+   and slices of structs.
+
+   https://github.com/owncloud/ocis/issues/9700
+   https://github.com/owncloud/ocis/pull/9722
+
+* Enhancement - Add an API to auth-app service: [#9755](https://github.com/owncloud/ocis/pull/9755)
+
+   Adds an API to create, list and delete app tokens. Includes an impersonification
+   feature for migration scenarios.
+
+   https://github.com/owncloud/ocis/pull/9755
+
 * Enhancement - Bump go-micro plugins pkg: [#9756](https://github.com/owncloud/ocis/pull/9756)
 
    Bump plugins pkg to include fix for cache delete
diff --git a/changelog/unreleased/added-generyc-translator.md b/changelog/unreleased/added-generyc-translator.md
new file mode 100644
index 00000000000..60cc9ee8fff
--- /dev/null
+++ b/changelog/unreleased/added-generyc-translator.md
@@ -0,0 +1,8 @@
+Enhancement: Added generic way to translate composite entities
+
+Added a generic way to translate the necessary fields in composite entities.
+The function takes the entity, translation function and fields to translate that are described by the TranslateField function.
+The function supports nested structs and slices of structs.
+
+https://github.com/owncloud/ocis/pull/9722
+https://github.com/owncloud/ocis/issues/9700
diff --git a/changelog/unreleased/auth-app-api.md b/changelog/unreleased/auth-app-api.md
new file mode 100644
index 00000000000..a98f9e4b945
--- /dev/null
+++ b/changelog/unreleased/auth-app-api.md
@@ -0,0 +1,5 @@
+Enhancement: Add an API to auth-app service
+
+Adds an API to create, list and delete app tokens. Includes an impersonification feature for migration scenarios.
+
+https://github.com/owncloud/ocis/pull/9755
diff --git a/changelog/unreleased/idp-config-generation-fix.md b/changelog/unreleased/idp-config-generation-fix.md
new file mode 100644
index 00000000000..3d009eb5fed
--- /dev/null
+++ b/changelog/unreleased/idp-config-generation-fix.md
@@ -0,0 +1,5 @@
+Bugfix: We fixed the client config generation for the built in IDP
+
+We now use the OCIS_URL to generate the web client registration configuration. It does not make sense use the OCIS_ISSUER_URL if the idp was configured to run on a different domain.
+
+https://github.com/owncloud/ocis/pull/9770
diff --git a/docs/apis/http/graph/permissions.md b/docs/apis/http/graph/permissions.md
new file mode 100644
index 00000000000..d6c957f1070
--- /dev/null
+++ b/docs/apis/http/graph/permissions.md
@@ -0,0 +1,184 @@
+---
+title: Permissions
+weight: 50
+geekdocRepo: https://github.com/owncloud/ocis
+geekdocEditPath: edit/master/docs/apis/http/graph
+geekdocFilePath: permissions.md
+---
+
+{{< toc >}}
+
+## Permissions API
+
+The Permissions API is implementing a subset of the functionality of the
+[MS Graph Permission resource](https://learn.microsoft.com/en-us/graph/api/resources/permission?view=graph-rest-1.0).
+
+### Example Permissions
+
+The JSON representation of a Drive, as handled by the Spaces API, looks like this:
+````json
+{
+  "@libre.graph.permissions.roles.allowedValues": [
+    {
+      "id": "b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5",
+      "description": "Allows reading the shared file or folder",
+      "displayName": "Viewer",
+      "@libre.graph.weight": 1
+    },
+    {
+      "id": "fb6c3e19-e378-47e5-b277-9732f9de6e21",
+      "description": "Allows reading and writing the shared file or folder",
+      "displayName": "Editor",
+      "@libre.graph.weight": 2
+    },
+    {
+      "id": "312c0871-5ef7-4b3a-85b6-0e4074c64049",
+      "description": "Allows managing a space",
+      "displayName": "Manager",
+      "@libre.graph.weight": 3
+    },
+    {
+      "id": "4916f47e-66d5-49bb-9ac9-748ad00334b",
+      "description": "Allows creating new files",
+      "displayName": "File Drop",
+      "@libre.graph.weight": 4
+    }
+  ],
+  "@libre.graph.permissions.actions.allowedValues": [
+    "libre.graph/driveItem/basic/read",
+    "libre.graph/driveItem/permissions/read",
+    "libre.graph/driveItem/upload/create",
+    "libre.graph/driveItem/standard/allTasks",
+    "libre.graph/driveItem/upload/create"
+  ],
+  "value": [
+    {
+      "id": "67445fde-a647-4dd4-b015-fc5dafd2821d",
+      "link": {
+        "type": "view",
+        "webUrl": "https://cloud.example.org/s/fhGBMIkKFEHWysj"
+      }
+    },
+    {
+      "id": "34646ab6-be32-43c9-89e6-987e0c237e9b",
+      "roles": [
+        "b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5"
+      ],
+      "grantedToV2": [
+        {
+          "user": {
+            "id": "4c510ada-c86b-4815-8820-42cdf82c3d51",
+            "displayName": "Albert Einstein"
+          }
+        }
+      ]
+    },
+    {
+      "id": "81d5bad3-3eff-410a-a2ea-eda2d14d4474",
+      "roles": [
+        "b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5"
+      ],
+      "grantedToV2": [
+        {
+          "user": {
+            "id": "4c510ada-c86b-4815-8820-42cdf82c3d51",
+            "displayName": "Albert Einstein"
+          }
+        }
+      ]
+    },
+    {
+      "id": "b470677e-a7f5-4304-8ef5-f5056a21fff1",
+      "roles": [
+        "b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5"
+      ],
+      "grantedToV2": [
+        {
+          "user": {
+            "id": "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c",
+            "displayName": "Marie Skłodowska Curie"
+          }
+        }
+      ]
+    },
+    {
+      "id": "453b02be-4ec2-4e7d-b576-09fc153de812",
+      "roles": [
+        "fb6c3e19-e378-47e5-b277-9732f9de6e21"
+      ],
+      "grantedToV2": [
+        {
+          "user": {
+            "id": "4c510ada-c86b-4815-8820-42cdf82c3d51",
+            "displayName": "Albert Einstein"
+          }
+        }
+      ],
+      "expirationDateTime": "2018-07-15T14:00:00.000Z"
+    },
+    {
+      "id": "86765c0d-3905-444a-9b07-76201f8cf7df",
+      "roles": [
+        "312c0871-5ef7-4b3a-85b6-0e4074c64049"
+      ],
+      "grantedToV2": [
+        {
+          "group": {
+            "id": "167cbee2-0518-455a-bfb2-031fe0621e5d",
+            "displayName": "Philosophy Haters"
+          }
+        }
+      ]
+    },
+    {
+      "id": "c42b5cbd-2d65-42cf-b0b6-fb6d2b762256",
+      "grantedToV2": [
+        {
+          "user": {
+            "id": "4c510ada-c86b-4815-8820-42cdf82c3d51",
+            "displayName": "Albert Einstein"
+          }
+        }
+      ],
+      "@libre.graph.permissions.actions": [
+        "libre.graph/driveItem/basic/read",
+        "libre.graph/driveItem/path/update"
+      ]
+    }
+  ]
+}
+````
+
+## Creating Share Invitation / Link
+
+### Create a link share `POST /drives/{drive-id}/items/{item-id}/createLink`
+
+https://owncloud.dev/libre-graph-api/#/drives.permissions/CreateLink
+
+### Create a user/group share `POST /drives/{drive-id}/items/{item-id}/invite`
+
+https://owncloud.dev/libre-graph-api/#/drives.permissions/Invite
+
+## Reading Permissions
+
+### List the effective sharing permissions on a driveitem `GET /drives/{drive-id}/items/{item-id}/permissions`
+
+https://owncloud.dev/libre-graph-api/#/drives.permissions/ListPermissions
+
+### List Get sharing permission for a file or folder `GET /drives/{drive-id}/items/{item-id}/permissions/{perm-id}`
+
+https://owncloud.dev/libre-graph-api/#/drives.permissions/GetPermission
+
+## Updating Permissions
+
+### Updating sharing permission `POST /drives/{drive-id}/items/{item-id}/permissions/{perm-id}`
+
+https://owncloud.dev/libre-graph-api/#/drives.permissions/UpdatePermission
+
+### Set password of permission `POST /drives/{drive-id}/items/{item-id}/permissions/{perm-id}/setPassword`
+
+https://owncloud.dev/libre-graph-api/#/drives.permissions/SetPermissionPassword
+
+### Deleting permission `DELETE /drives/{drive-id}/items/{item-id}/permissions/{perm-id}`
+
+https://owncloud.dev/libre-graph-api/#/drives.permissions/DeletePermission
\ No newline at end of file
diff --git a/go.mod b/go.mod
index d29495ee3d1..bdf6efdaa67 100644
--- a/go.mod
+++ b/go.mod
@@ -105,7 +105,7 @@ require (
 	golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3
 	golang.org/x/image v0.18.0
 	golang.org/x/net v0.27.0
-	golang.org/x/oauth2 v0.21.0
+	golang.org/x/oauth2 v0.22.0
 	golang.org/x/sync v0.7.0
 	golang.org/x/term v0.22.0
 	golang.org/x/text v0.16.0
diff --git a/go.sum b/go.sum
index 3d36d288f0d..be27e17a3d9 100644
--- a/go.sum
+++ b/go.sum
@@ -1411,8 +1411,8 @@ golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4Iltr
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
-golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
+golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/ocis-pkg/l10n/l10n.go b/ocis-pkg/l10n/l10n.go
index 5c99c399a7d..58680666f85 100644
--- a/ocis-pkg/l10n/l10n.go
+++ b/ocis-pkg/l10n/l10n.go
@@ -6,6 +6,7 @@ import (
 	"errors"
 	"io/fs"
 	"os"
+	"reflect"
 
 	"github.com/leonelquinteros/gotext"
 	"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
@@ -14,8 +15,13 @@ import (
 	micrometadata "go-micro.dev/v4/metadata"
 )
 
-// HeaderAcceptLanguage is the header key for the accept-language header
-var HeaderAcceptLanguage = "Accept-Language"
+var (
+	// HeaderAcceptLanguage is the header key for the accept-language header
+	HeaderAcceptLanguage = "Accept-Language"
+
+	// ErrUnsupportedType is returned when the type is not supported
+	ErrUnsupportedType = errors.New("unsupported type")
+)
 
 // Template marks a string as translatable
 func Template(s string) string { return s }
@@ -63,6 +69,61 @@ func (t Translator) Locale(locale string) *gotext.Locale {
 	return l
 }
 
+// TranslateEntity function provides the generic way to translate a struct, array or slice.
+// Support for maps is also provided, but non-pointer values will not work.
+// The function also takes the entity with fields to translate.
+// The function supports nested structs and slices of structs.
+/*
+tr := NewTranslator("en", _domain, _fsys)
+
+// a slice of translatables can	be passed directly
+val := []string{"description", "display name"}
+err := tr.TranslateEntity(tr, s, val)
+
+// string maps work the same way
+val := map[string]string{
+	"entryOne": "description",
+	"entryTwo": "display name",
+}
+err := TranslateEntity(tr, val)
+
+// struct fields need to be specified
+type Struct struct {
+	Description string
+	DisplayName string
+	MetaInformation string
+}
+val := Struct{}
+err := TranslateEntity(tr, val,
+	l10n.TranslateField("Description"),
+	l10n.TranslateField("DisplayName"),
+)
+
+// nested structures are supported
+type InnerStruct struct {
+	Description string
+	Roles []string
+}
+type OuterStruct struct {
+	DisplayName string
+	First InnerStruct
+	Others map[string]InnerStruct
+}
+val := OuterStruct{}
+err := TranslateEntity(tr, val,
+	l10n.TranslateField("DisplayName"),
+	l10n.TranslateStruct("First",
+		l10n.TranslateField("Description"),
+		l10n.TranslateEach("Roles"),
+	),
+	l10n.TranslateMap("Others",
+		l10n.TranslateField("Description"),
+	},
+*/
+func (t Translator) TranslateEntity(locale string, entity any, opts ...TranslateOption) error {
+	return TranslateEntity(t.Locale(locale).Get, entity, opts...)
+}
+
 // MustGetUserLocale returns the locale the user wants to use, omitting errors
 func MustGetUserLocale(ctx context.Context, userID string, preferedLang string, vc settingssvc.ValueService) string {
 	if preferedLang != "" {
@@ -91,3 +152,194 @@ func GetUserLocale(ctx context.Context, userID string, vc settingssvc.ValueServi
 	}
 	return val[0].GetStringValue(), nil
 }
+
+// TranslateOption is used to specify fields in structs to translate
+type TranslateOption func() (string, FieldType, []TranslateOption)
+
+// FieldType is used to specify the type of field to translate
+type FieldType int
+
+const (
+	// FieldTypeString is a string field
+	FieldTypeString FieldType = iota
+	// FieldTypeStruct is a struct field
+	FieldTypeStruct
+	// FieldTypeIterable is a slice or array field
+	FieldTypeIterable
+	// FieldTypeMap is a map field
+	FieldTypeMap
+)
+
+// TranslateField function provides the generic way to translate the necessary field in composite entities.
+func TranslateField(fieldName string) TranslateOption {
+	return func() (string, FieldType, []TranslateOption) {
+		return fieldName, FieldTypeString, nil
+	}
+}
+
+// TranslateStruct function provides the generic way to translate the nested fields in composite entities.
+func TranslateStruct(fieldName string, args ...TranslateOption) TranslateOption {
+	return func() (string, FieldType, []TranslateOption) {
+		return fieldName, FieldTypeStruct, args
+	}
+}
+
+// TranslateEach function provides the generic way to translate the necessary fields in slices or nested entities.
+func TranslateEach(fieldName string, args ...TranslateOption) TranslateOption {
+	return func() (string, FieldType, []TranslateOption) {
+		return fieldName, FieldTypeIterable, args
+	}
+}
+
+// TranslateMap function provides the generic way to translate the necessary fields in maps.
+func TranslateMap(fieldName string, args ...TranslateOption) TranslateOption {
+	return func() (string, FieldType, []TranslateOption) {
+		return fieldName, FieldTypeMap, args
+	}
+}
+
+// TranslateEntity translates a slice, array or struct
+// See Translator.TranslateEntity for more information
+func TranslateEntity(tr func(string, ...any) string, entity any, opts ...TranslateOption) error {
+	value := reflect.ValueOf(entity)
+
+	value, ok := cleanValue(value)
+	if !ok {
+		return errors.New("entity is not valid")
+	}
+
+	switch value.Kind() {
+	case reflect.Struct:
+		rangeOverArgs(tr, value, opts...)
+	case reflect.Slice, reflect.Array, reflect.Map:
+		translateEach(tr, value, opts...)
+	case reflect.String:
+		translateField(tr, value)
+	default:
+		return ErrUnsupportedType
+	}
+	return nil
+}
+
+func translateEach(tr func(string, ...any) string, value reflect.Value, args ...TranslateOption) {
+	value, ok := cleanValue(value)
+	if !ok {
+		return
+	}
+
+	switch value.Kind() {
+	case reflect.Array, reflect.Slice:
+		for i := 0; i < value.Len(); i++ {
+			v := value.Index(i)
+			switch v.Kind() {
+			case reflect.Struct, reflect.Ptr:
+				rangeOverArgs(tr, v, args...)
+			case reflect.String:
+				translateField(tr, v)
+			case reflect.Slice, reflect.Array, reflect.Map:
+				translateEach(tr, v, args...)
+			}
+		}
+	case reflect.Map:
+		for _, k := range value.MapKeys() {
+			v := value.MapIndex(k)
+			switch v.Kind() {
+			case reflect.Struct:
+				// FIXME: add support for non-pointer values
+			case reflect.Pointer:
+				rangeOverArgs(tr, v, args...)
+			case reflect.String:
+				if nv := tr(v.String()); nv != "" {
+					value.SetMapIndex(k, reflect.ValueOf(nv))
+				}
+			case reflect.Slice, reflect.Array, reflect.Map:
+				translateEach(tr, v, args...)
+			}
+		}
+	}
+}
+
+func rangeOverArgs(tr func(string, ...any) string, value reflect.Value, args ...TranslateOption) {
+	value, ok := cleanValue(value)
+	if !ok {
+		return
+	}
+
+	for _, arg := range args {
+		fieldName, fieldType, opts := arg()
+
+		switch fieldType {
+		case FieldTypeString:
+			f := value.FieldByName(fieldName)
+			translateField(tr, f)
+		case FieldTypeStruct:
+			innerValue := value.FieldByName(fieldName)
+			if !innerValue.IsValid() || !isStruct(innerValue) {
+				return
+			}
+			rangeOverArgs(tr, innerValue, opts...)
+		case FieldTypeIterable:
+			innerValue := value.FieldByName(fieldName)
+			if !innerValue.IsValid() {
+				return
+			}
+			if kind := innerValue.Kind(); kind != reflect.Array && kind != reflect.Slice {
+				return
+			}
+			translateEach(tr, innerValue, opts...)
+		case FieldTypeMap:
+			innerValue := value.FieldByName(fieldName)
+			if !innerValue.IsValid() {
+				return
+			}
+			if kind := innerValue.Kind(); kind != reflect.Map {
+				return
+			}
+			translateEach(tr, innerValue, opts...)
+		}
+	}
+}
+
+func translateField(tr func(string, ...any) string, f reflect.Value) {
+	if f.IsValid() {
+		if f.Kind() == reflect.Ptr {
+			if f.IsNil() {
+				return
+			}
+			f = f.Elem()
+		}
+		// A Value can be changed only if it is
+		// addressable and was not obtained by
+		// the use of unexported struct fields.
+		if f.CanSet() {
+			// change value
+			if f.Kind() == reflect.String {
+				val := tr(f.String())
+				if val == "" {
+					return
+				}
+				f.SetString(val)
+			}
+		}
+	}
+}
+
+func isStruct(r reflect.Value) bool {
+	if r.Kind() == reflect.Ptr {
+		r = r.Elem()
+	}
+	return r.Kind() == reflect.Struct
+}
+
+func cleanValue(v reflect.Value) (reflect.Value, bool) {
+	if v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
+		if v.IsNil() {
+			return v, false
+		}
+		v = v.Elem()
+	}
+	if !v.IsValid() {
+		return v, false
+	}
+	return v, true
+}
diff --git a/services/auth-app/README.md b/services/auth-app/README.md
index 012490527e9..86896ba0557 100644
--- a/services/auth-app/README.md
+++ b/services/auth-app/README.md
@@ -21,10 +21,23 @@ PROXY_ENABLE_APP_AUTH=true      # mandatory, allow app authentication. In case o
 
 ## App Tokens
 
-App Tokens are used to authenticate 3rd party access via https like when using curl (apps) to access an API endpoint. These apps need to authenticate themselves as no logged in user authenticates the request. To be able to use an app token, one must first create a token via the cli. Replace the `user-name` with an existing user. For the `token-expiration`, you can use any time abbreviation from the following list: `h, m, s`. Examples: `72h` or `1h` or `1m` or `1s.` Default is `72h`.
+App Tokens are used to authenticate 3rd party access via https like when using curl (apps) to access an API endpoint. These apps need to authenticate themselves as no logged in user authenticates the request. To be able to use an app token, one must first create a token. There are different options of creating a token.
+
+### Via CLI (dev only)
+
+Replace the `user-name` with an existing user. For the `token-expiration`, you can use any time abbreviation from the following list: `h, m, s`. Examples: `72h` or `1h` or `1m` or `1s.` Default is `72h`.
 
 ```bash
 ocis auth-app create --user-name={user-name} --expiration={token-expiration}
 ```
 
 Once generated, these tokens can be used to authenticate requests to ocis. They are passed as part of the request as `Basic Auth` header.
+
+### Via API
+
+The `auth-app` service provides an API to create (POST), list (GET) and delete (DELETE) tokens at `/auth-app/tokens`.
+
+### Via Impersonation API
+
+When setting the environment variable `AUTH_APP_ENABLE_IMPERSONATION` to `true`, admins will be able to use the `/auth-app/tokens` endpoint to create tokens for other users. This is crucial for migration scenarios,
+but should not be used on a productive system.
diff --git a/services/auth-app/pkg/command/server.go b/services/auth-app/pkg/command/server.go
index 323e203c73c..77cb36431a2 100644
--- a/services/auth-app/pkg/command/server.go
+++ b/services/auth-app/pkg/command/server.go
@@ -7,18 +7,22 @@ import (
 	"path"
 
 	"github.com/cs3org/reva/v2/cmd/revad/runtime"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
 	"github.com/gofrs/uuid"
 	"github.com/oklog/run"
 	"github.com/owncloud/ocis/v2/ocis-pkg/config/configlog"
 	"github.com/owncloud/ocis/v2/ocis-pkg/registry"
+	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
 	"github.com/owncloud/ocis/v2/ocis-pkg/sync"
 	"github.com/owncloud/ocis/v2/ocis-pkg/tracing"
 	"github.com/owncloud/ocis/v2/ocis-pkg/version"
+	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
 	"github.com/owncloud/ocis/v2/services/auth-app/pkg/config"
 	"github.com/owncloud/ocis/v2/services/auth-app/pkg/config/parser"
 	"github.com/owncloud/ocis/v2/services/auth-app/pkg/logging"
 	"github.com/owncloud/ocis/v2/services/auth-app/pkg/revaconfig"
 	"github.com/owncloud/ocis/v2/services/auth-app/pkg/server/debug"
+	"github.com/owncloud/ocis/v2/services/auth-app/pkg/server/http"
 	"github.com/urfave/cli/v2"
 )
 
@@ -32,6 +36,10 @@ func Server(cfg *config.Config) *cli.Command {
 			return configlog.ReturnFatal(parser.ParseConfig(cfg))
 		},
 		Action: func(c *cli.Context) error {
+			if cfg.AllowImpersonation {
+				fmt.Println("WARNING: Impersonation is enabled. Admins can impersonate all users.")
+			}
+
 			logger := logging.Configure(cfg.Service.Name, cfg.Log)
 			traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name)
 			if err != nil {
@@ -86,6 +94,47 @@ func Server(cfg *config.Config) *cli.Command {
 				logger.Fatal().Err(err).Msg("failed to register the grpc service")
 			}
 
+			tm, err := pool.StringToTLSMode(cfg.GRPCClientTLS.Mode)
+			if err != nil {
+				return err
+			}
+			gatewaySelector, err := pool.GatewaySelector(
+				cfg.Reva.Address,
+				append(
+					cfg.Reva.GetRevaOptions(),
+					pool.WithTLSCACert(cfg.GRPCClientTLS.CACert),
+					pool.WithTLSMode(tm),
+					pool.WithRegistry(registry.GetRegistry()),
+					pool.WithTracerProvider(traceProvider),
+				)...)
+			if err != nil {
+				return err
+			}
+
+			grpcClient, err := ogrpc.NewClient(
+				append(ogrpc.GetClientOptions(cfg.GRPCClientTLS), ogrpc.WithTraceProvider(traceProvider))...,
+			)
+			if err != nil {
+				return err
+			}
+
+			rClient := settingssvc.NewRoleService("com.owncloud.api.settings", grpcClient)
+			server, err := http.Server(
+				http.Logger(logger),
+				http.Context(ctx),
+				http.Config(cfg),
+				http.GatewaySelector(gatewaySelector),
+				http.RoleClient(rClient),
+				http.TracerProvider(traceProvider),
+			)
+			if err != nil {
+				logger.Fatal().Err(err).Msg("failed to initialize http server")
+			}
+
+			gr.Add(server.Run, func(err error) {
+				logger.Error().Err(err).Str("server", "http").Msg("shutting down server")
+			})
+
 			return gr.Run()
 		},
 	}
diff --git a/services/auth-app/pkg/config/config.go b/services/auth-app/pkg/config/config.go
index 01d58b81eb5..175b74fd8aa 100644
--- a/services/auth-app/pkg/config/config.go
+++ b/services/auth-app/pkg/config/config.go
@@ -15,6 +15,9 @@ type Config struct {
 	Debug   Debug           `yaml:"debug"`
 
 	GRPC GRPCConfig `yaml:"grpc"`
+	HTTP HTTP       `yaml:"http"`
+
+	GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
 
 	TokenManager *TokenManager `yaml:"token_manager"`
 	Reva         *shared.Reva  `yaml:"reva"`
@@ -23,6 +26,8 @@ type Config struct {
 
 	MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;AUTH_APP_MACHINE_AUTH_API_KEY" desc:"The machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"%%NEXT%%"`
 
+	AllowImpersonation bool `yaml:"allow_impersonation" env:"AUTH_APP_ENABLE_IMPERSONATION" desc:"Allows admins to create app tokens for other users. Used for migration. Do NOT use in productive deployments." introductionVersion:"%%NEXT%%"`
+
 	Supervised bool            `yaml:"-"`
 	Context    context.Context `yaml:"-"`
 }
@@ -55,3 +60,20 @@ type GRPCConfig struct {
 	Namespace string                 `yaml:"-"`
 	Protocol  string                 `yaml:"protocol" env:"AUTH_APP_GRPC_PROTOCOL" desc:"The transport protocol of the GRPC service." introductionVersion:"%%NEXT%%"`
 }
+
+// HTTP defines the available http configuration.
+type HTTP struct {
+	Addr      string                `yaml:"addr" env:"AUTH_APP_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"pre5.0"`
+	Namespace string                `yaml:"-"`
+	Root      string                `yaml:"root" env:"AUTH_APP_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"pre5.0"`
+	CORS      CORS                  `yaml:"cors"`
+	TLS       shared.HTTPServiceTLS `yaml:"tls"`
+}
+
+// CORS defines the available cors configuration.
+type CORS struct {
+	AllowedOrigins   []string `yaml:"allow_origins" env:"OCIS_CORS_ALLOW_ORIGINS;AUTH_APP_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"pre5.0"`
+	AllowedMethods   []string `yaml:"allow_methods" env:"OCIS_CORS_ALLOW_METHODS;AUTH_APP_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"pre5.0"`
+	AllowedHeaders   []string `yaml:"allow_headers" env:"OCIS_CORS_ALLOW_HEADERS;AUTH_APP_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"pre5.0"`
+	AllowCredentials bool     `yaml:"allow_credentials" env:"OCIS_CORS_ALLOW_CREDENTIALS;AUTH_APP_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"pre5.0"`
+}
diff --git a/services/auth-app/pkg/config/defaults/defaultconfig.go b/services/auth-app/pkg/config/defaults/defaultconfig.go
index 19953bcef28..01215d1fe56 100644
--- a/services/auth-app/pkg/config/defaults/defaultconfig.go
+++ b/services/auth-app/pkg/config/defaults/defaultconfig.go
@@ -1,6 +1,8 @@
 package defaults
 
 import (
+	"strings"
+
 	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
 	"github.com/owncloud/ocis/v2/ocis-pkg/structs"
 	"github.com/owncloud/ocis/v2/services/auth-app/pkg/config"
@@ -28,6 +30,17 @@ func DefaultConfig() *config.Config {
 			Namespace: "com.owncloud.api",
 			Protocol:  "tcp",
 		},
+		HTTP: config.HTTP{
+			Addr:      "127.0.0.1:9247",
+			Namespace: "com.owncloud.web",
+			Root:      "/",
+			CORS: config.CORS{
+				AllowedOrigins:   []string{"*"},
+				AllowedMethods:   []string{"GET", "POST", "DELETE"},
+				AllowedHeaders:   []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Ocs-Apirequest"},
+				AllowCredentials: true,
+			},
+		},
 		Service: config.Service{
 			Name: "auth-app",
 		},
@@ -60,6 +73,10 @@ func EnsureDefaults(cfg *config.Config) {
 		cfg.Tracing = &config.Tracing{}
 	}
 
+	if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
+		cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
+	}
+
 	if cfg.Reva == nil && cfg.Commons != nil {
 		cfg.Reva = structs.CopyOrZeroValue(cfg.Commons.Reva)
 	}
@@ -79,9 +96,16 @@ func EnsureDefaults(cfg *config.Config) {
 	if cfg.GRPC.TLS == nil && cfg.Commons != nil {
 		cfg.GRPC.TLS = structs.CopyOrZeroValue(cfg.Commons.GRPCServiceTLS)
 	}
+
+	if cfg.Commons != nil {
+		cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
+	}
 }
 
 // Sanitize sanitized the configuration
-func Sanitize(_ *config.Config) {
-	// nothing to sanitize here atm
+func Sanitize(cfg *config.Config) {
+	// sanitize config
+	if cfg.HTTP.Root != "/" {
+		cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
+	}
 }
diff --git a/services/auth-app/pkg/server/http/option.go b/services/auth-app/pkg/server/http/option.go
new file mode 100644
index 00000000000..1d141e9e34f
--- /dev/null
+++ b/services/auth-app/pkg/server/http/option.go
@@ -0,0 +1,95 @@
+package http
+
+import (
+	"context"
+
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
+	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+	"github.com/owncloud/ocis/v2/services/auth-app/pkg/config"
+	"github.com/urfave/cli/v2"
+	"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
+	Context         context.Context
+	Config          *config.Config
+	Flags           []cli.Flag
+	Namespace       string
+	GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
+	RoleClient      settingssvc.RoleService
+	TracerProvider  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
+	}
+}
+
+// Flags provides a function to set the flags option.
+func Flags(val []cli.Flag) Option {
+	return func(o *Options) {
+		o.Flags = append(o.Flags, val...)
+	}
+}
+
+// Namespace provides a function to set the Namespace option.
+func Namespace(val string) Option {
+	return func(o *Options) {
+		o.Namespace = val
+	}
+}
+
+// GatewaySelector provides a function to configure the gateway client selector
+func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
+	return func(o *Options) {
+		o.GatewaySelector = gatewaySelector
+	}
+}
+
+// RoleClient adds a grpc client for the role service
+func RoleClient(rs settingssvc.RoleService) Option {
+	return func(o *Options) {
+		o.RoleClient = rs
+	}
+}
+
+// TracerProvider provides a function to set the TracerProvider option
+func TracerProvider(val trace.TracerProvider) Option {
+	return func(o *Options) {
+		o.TracerProvider = val
+	}
+}
diff --git a/services/auth-app/pkg/server/http/server.go b/services/auth-app/pkg/server/http/server.go
new file mode 100644
index 00000000000..4c18d1e2fb2
--- /dev/null
+++ b/services/auth-app/pkg/server/http/server.go
@@ -0,0 +1,97 @@
+package http
+
+import (
+	"fmt"
+
+	stdhttp "net/http"
+
+	"github.com/go-chi/chi/v5"
+	chimiddleware "github.com/go-chi/chi/v5/middleware"
+	"github.com/owncloud/ocis/v2/ocis-pkg/account"
+	"github.com/owncloud/ocis/v2/ocis-pkg/cors"
+	"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
+	"github.com/owncloud/ocis/v2/ocis-pkg/service/http"
+	"github.com/owncloud/ocis/v2/ocis-pkg/tracing"
+	"github.com/owncloud/ocis/v2/ocis-pkg/version"
+	svc "github.com/owncloud/ocis/v2/services/auth-app/pkg/service"
+	"github.com/riandyrn/otelchi"
+	"go-micro.dev/v4"
+)
+
+// Service is the service interface
+type Service interface{}
+
+// Server initializes the http service and server.
+func Server(opts ...Option) (http.Service, error) {
+	options := newOptions(opts...)
+
+	service, err := http.NewService(
+		http.TLSConfig(options.Config.HTTP.TLS),
+		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.TraceProvider(options.TracerProvider),
+	)
+	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)
+	}
+
+	middlewares := []func(stdhttp.Handler) stdhttp.Handler{
+		chimiddleware.RequestID,
+		middleware.Version(
+			options.Config.Service.Name,
+			version.GetString(),
+		),
+		middleware.Logger(
+			options.Logger,
+		),
+		middleware.ExtractAccountUUID(
+			account.Logger(options.Logger),
+			account.JWTSecret(options.Config.TokenManager.JWTSecret),
+		),
+		middleware.Cors(
+			cors.Logger(options.Logger),
+			cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
+			cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
+			cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
+			cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
+		),
+	}
+
+	mux := chi.NewMux()
+	mux.Use(middlewares...)
+
+	mux.Use(
+		otelchi.Middleware(
+			"auth-app",
+			otelchi.WithChiRoutes(mux),
+			otelchi.WithTracerProvider(options.TracerProvider),
+			otelchi.WithPropagators(tracing.GetPropagator()),
+		),
+	)
+
+	handle, err := svc.NewAuthAppService(
+		svc.Logger(options.Logger),
+		svc.Mux(mux),
+		svc.Config(options.Config),
+		svc.GatewaySelector(options.GatewaySelector),
+		svc.RoleClient(options.RoleClient),
+		svc.TraceProvider(options.TracerProvider),
+	)
+	if err != nil {
+		return http.Service{}, err
+	}
+
+	if err := micro.RegisterHandler(service.Server(), handle); err != nil {
+		return http.Service{}, err
+	}
+
+	return service, nil
+}
diff --git a/services/auth-app/pkg/service/option.go b/services/auth-app/pkg/service/option.go
new file mode 100644
index 00000000000..967001c7cde
--- /dev/null
+++ b/services/auth-app/pkg/service/option.go
@@ -0,0 +1,76 @@
+package service
+
+import (
+	"context"
+
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
+	"github.com/go-chi/chi/v5"
+	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+	"github.com/owncloud/ocis/v2/services/auth-app/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
+	Context         context.Context
+	Config          *config.Config
+	GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
+	Mux             *chi.Mux
+	TracerProvider  trace.TracerProvider
+	RoleClient      settingssvc.RoleService
+}
+
+// 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
+	}
+}
+
+// GatewaySelector provides a function to configure the gateway client selector
+func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
+	return func(o *Options) {
+		o.GatewaySelector = gatewaySelector
+	}
+}
+
+// TraceProvider provides a function to set the TracerProvider option
+func TraceProvider(val trace.TracerProvider) Option {
+	return func(o *Options) {
+		o.TracerProvider = val
+	}
+}
+
+// Mux defines the muxer for the userlog service
+func Mux(m *chi.Mux) Option {
+	return func(o *Options) {
+		o.Mux = m
+	}
+}
+
+// RoleClient adds a grpc client for the role service
+func RoleClient(rs settingssvc.RoleService) Option {
+	return func(o *Options) {
+		o.RoleClient = rs
+	}
+}
diff --git a/services/auth-app/pkg/service/service.go b/services/auth-app/pkg/service/service.go
new file mode 100644
index 00000000000..53de039fe8a
--- /dev/null
+++ b/services/auth-app/pkg/service/service.go
@@ -0,0 +1,317 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"net/http"
+	"time"
+
+	applications "github.com/cs3org/go-cs3apis/cs3/auth/applications/v1beta1"
+	authpb "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1"
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+	userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	"github.com/cs3org/reva/v2/pkg/appctx"
+	"github.com/cs3org/reva/v2/pkg/auth/scope"
+	ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
+	"github.com/cs3org/reva/v2/pkg/utils"
+	"github.com/go-chi/chi/v5"
+	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	"github.com/owncloud/ocis/v2/ocis-pkg/roles"
+	"github.com/owncloud/ocis/v2/services/auth-app/pkg/config"
+	settings "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
+	"google.golang.org/grpc/metadata"
+)
+
+// AuthAppToken represents an app token.
+type AuthAppToken struct {
+	Token          string    `json:"token"`
+	ExpirationDate time.Time `json:"expiration_date"`
+	CreatedDate    time.Time `json:"created_date"`
+	Label          string    `json:"label"`
+}
+
+// AuthAppService defines the service interface.
+type AuthAppService struct {
+	log log.Logger
+	cfg *config.Config
+	gws pool.Selectable[gateway.GatewayAPIClient]
+	m   *chi.Mux
+	r   *roles.Manager
+}
+
+// NewAuthAppService initializes a new AuthAppService.
+func NewAuthAppService(opts ...Option) (*AuthAppService, error) {
+	o := &Options{}
+	for _, opt := range opts {
+		opt(o)
+	}
+
+	r := roles.NewManager(
+		roles.Logger(o.Logger),
+		roles.RoleService(o.RoleClient),
+	)
+
+	a := &AuthAppService{
+		log: o.Logger,
+		cfg: o.Config,
+		gws: o.GatewaySelector,
+		m:   o.Mux,
+		r:   &r,
+	}
+
+	a.m.Route("/auth-app/tokens", func(r chi.Router) {
+		r.Get("/", a.HandleList)
+		r.Post("/", a.HandleCreate)
+		r.Delete("/", a.HandleDelete)
+	})
+
+	return a, nil
+}
+
+// ServeHTTP implements the http.Handler interface.
+func (a *AuthAppService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	a.m.ServeHTTP(w, r)
+}
+
+// HandleCreate handles the creation of app tokens
+func (a *AuthAppService) HandleCreate(w http.ResponseWriter, r *http.Request) {
+	ctx := getContext(r)
+	sublog := a.log.With().Str("actor", ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId()).Logger()
+
+	gwc, err := a.gws.Next()
+	if err != nil {
+		sublog.Error().Err(err).Msg("error getting gateway client")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	q := r.URL.Query()
+	expiry, err := time.ParseDuration(q.Get("expiry"))
+	if err != nil {
+		sublog.Info().Err(err).Str("duration", q.Get("expiry")).Msg("error parsing expiry")
+		http.Error(w, "error parsing expiry. Use e.g. 30m or 72h", http.StatusBadRequest)
+		return
+	}
+
+	label := "Generated via API"
+	cid := buildClientID(q.Get("userID"), q.Get("userName"))
+	if cid != "" {
+		if !a.cfg.AllowImpersonation {
+			sublog.Error().Msg("impersonation is not allowed")
+			http.Error(w, "impersonation is not allowed", http.StatusForbidden)
+			return
+		}
+		ok, err := isAdmin(ctx, a.r)
+		if err != nil {
+			sublog.Error().Err(err).Msg("error checking if user is admin")
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+		if !ok {
+			sublog.Error().Msg("user is not admin")
+			w.WriteHeader(http.StatusForbidden)
+			return
+		}
+		ctx, err = a.authenticateUser(cid, gwc)
+		if err != nil {
+			sublog.Error().Err(err).Msg("error authenticating user")
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+
+		label = "Generated via Impersonation API"
+	}
+
+	scopes, err := scope.AddOwnerScope(map[string]*authpb.Scope{})
+	if err != nil {
+		sublog.Error().Err(err).Msg("error adding owner scope")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	res, err := gwc.GenerateAppPassword(ctx, &applications.GenerateAppPasswordRequest{
+		TokenScope: scopes,
+		Label:      label,
+		Expiration: utils.TimeToTS(time.Now().Add(expiry)),
+	})
+	if err != nil {
+		sublog.Error().Err(err).Msg("error generating app password")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		sublog.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error generating app password")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	b, err := json.Marshal(convert(res.GetAppPassword()))
+	if err != nil {
+		sublog.Error().Err(err).Msg("error marshaling app password")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	if _, err := w.Write(b); err != nil {
+		sublog.Error().Err(err).Msg("error writing response")
+	}
+	w.WriteHeader(http.StatusCreated)
+}
+
+// HandleList handles listing of app tokens
+func (a *AuthAppService) HandleList(w http.ResponseWriter, r *http.Request) {
+	ctx := getContext(r)
+	sublog := a.log.With().Str("actor", ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId()).Logger()
+
+	gwc, err := a.gws.Next()
+	if err != nil {
+		sublog.Error().Err(err).Msg("error getting gateway client")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	res, err := gwc.ListAppPasswords(ctx, &applications.ListAppPasswordsRequest{})
+	if err != nil {
+		sublog.Error().Err(err).Msg("error listing app passwords")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		sublog.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error listing app passwords")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	tokens := make([]AuthAppToken, 0, len(res.GetAppPasswords()))
+	for _, ap := range res.GetAppPasswords() {
+		tokens = append(tokens, convert(ap))
+	}
+
+	b, err := json.Marshal(tokens)
+	if err != nil {
+		sublog.Error().Err(err).Msg("error marshaling app passwords")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	if _, err := w.Write(b); err != nil {
+		sublog.Error().Err(err).Msg("error writing response")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleDelete handles deletion of app tokens
+func (a *AuthAppService) HandleDelete(w http.ResponseWriter, r *http.Request) {
+	ctx := getContext(r)
+	sublog := a.log.With().Str("actor", ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId()).Logger()
+
+	gwc, err := a.gws.Next()
+	if err != nil {
+		sublog.Error().Err(err).Msg("error getting gateway client")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	pw := r.URL.Query().Get("token")
+	if pw == "" {
+		sublog.Info().Msg("missing token")
+		http.Error(w, "missing auth-app token. Set 'token' parameter", http.StatusBadRequest)
+		return
+	}
+
+	res, err := gwc.InvalidateAppPassword(ctx, &applications.InvalidateAppPasswordRequest{Password: pw})
+	if err != nil {
+		sublog.Error().Err(err).Msg("error invalidating app password")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		sublog.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error invalidating app password")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+func (a *AuthAppService) authenticateUser(clientID string, gwc gateway.GatewayAPIClient) (context.Context, error) {
+	ctx := context.Background()
+	authRes, err := gwc.Authenticate(ctx, &gateway.AuthenticateRequest{
+		Type:         "machine",
+		ClientId:     clientID,
+		ClientSecret: a.cfg.MachineAuthAPIKey,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if authRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		return nil, errors.New("error authenticating user: " + authRes.GetStatus().GetMessage())
+	}
+
+	ctx = ctxpkg.ContextSetUser(ctx, &userpb.User{Id: authRes.GetUser().GetId()})
+	return metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, authRes.GetToken()), nil
+}
+
+func getContext(r *http.Request) context.Context {
+	ctx := r.Context()
+	return metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, r.Header.Get("X-Access-Token"))
+}
+
+func buildClientID(userID, userName string) string {
+	switch {
+	default:
+		return ""
+	case userID != "":
+		return "userid:" + userID
+	case userName != "":
+		return "username:" + userName
+	}
+}
+
+// isAdmin determines if the user in the context is an admin / has account management permissions
+func isAdmin(ctx context.Context, rm *roles.Manager) (bool, error) {
+	logger := appctx.GetLogger(ctx)
+
+	u, ok := ctxpkg.ContextGetUser(ctx)
+	uid := u.GetId().GetOpaqueId()
+	if !ok || uid == "" {
+		logger.Error().Str("userid", uid).Msg("user not in context")
+		return false, errors.New("no user in context")
+	}
+	// get roles from context
+	roleIDs, ok := roles.ReadRoleIDsFromContext(ctx)
+	if !ok {
+		logger.Debug().Str("userid", uid).Msg("No roles in context, contacting settings service")
+		var err error
+		roleIDs, err = rm.FindRoleIDsForUser(ctx, uid)
+		if err != nil {
+			logger.Err(err).Str("userid", uid).Msg("failed to get roles for user")
+			return false, err
+		}
+
+		if len(roleIDs) == 0 {
+			logger.Err(err).Str("userid", uid).Msg("user has no roles")
+			return false, errors.New("user has no roles")
+		}
+	}
+
+	// check if permission is present in roles of the authenticated account
+	return rm.FindPermissionByID(ctx, roleIDs, settings.AccountManagementPermissionID) != nil, nil
+}
+
+func convert(ap *applications.AppPassword) AuthAppToken {
+	return AuthAppToken{
+		Token:          ap.GetPassword(),
+		ExpirationDate: utils.TSToTime(ap.GetExpiration()),
+		CreatedDate:    utils.TSToTime(ap.GetCtime()),
+		Label:          ap.GetLabel(),
+	}
+}
diff --git a/services/graph/Makefile b/services/graph/Makefile
index 60a640b0691..2119f6ed5fd 100644
--- a/services/graph/Makefile
+++ b/services/graph/Makefile
@@ -2,8 +2,8 @@ SHELL := bash
 NAME := graph
 
 # Where to write the files generated by this makefile.
-OUTPUT_DIR = ./pkg/service/v0/l10n
-TEMPLATE_FILE = ./pkg/service/v0/l10n/graph.pot
+OUTPUT_DIR = ./pkg/l10n
+TEMPLATE_FILE = ./pkg/l10n/graph.pot
 
 include ../../.make/recursion.mk
 
@@ -45,7 +45,7 @@ l10n-push:
 
 .PHONY: l10n-read
 l10n-read: $(GO_XGETTEXT)
-	go-xgettext -o $(OUTPUT_DIR)/graph.pot --keyword=l10n.Template --add-comments -s pkg/service/v0/spacetemplates.go
+	go-xgettext -o $(OUTPUT_DIR)/graph.pot --keyword=l10n.Template --add-comments -s pkg/service/v0/spacetemplates.go -s pkg/unifiedrole/unifiedrole.go
 
 .PHONY: l10n-write
 l10n-write:
diff --git a/services/graph/pkg/service/v0/l10n/.tx/config b/services/graph/pkg/l10n/.tx/config
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/.tx/config
rename to services/graph/pkg/l10n/.tx/config
diff --git a/services/graph/pkg/service/v0/l10n/locale/bg/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/bg/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/bg/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/bg/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/de/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/de/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/de/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/de/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/en_GB/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/en_GB/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/en_GB/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/en_GB/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/es/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/es/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/es/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/es/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/fr/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/fr/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/fr/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/fr/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/fr_FR/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/fr_FR/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/fr_FR/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/fr_FR/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/ja_JP/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ja_JP/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/ja_JP/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/ja_JP/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/ko/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ko/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/ko/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/ko/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/nl/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/nl/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/nl/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/nl/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/pt_BR/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/pt_BR/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/pt_BR/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/pt_BR/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/ru/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ru/LC_MESSAGES/graph.po
similarity index 99%
rename from services/graph/pkg/service/v0/l10n/locale/ru/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/ru/LC_MESSAGES/graph.po
index 0eea275dc25..239b3eb78cf 100644
--- a/services/graph/pkg/service/v0/l10n/locale/ru/LC_MESSAGES/graph.po
+++ b/services/graph/pkg/l10n/locale/ru/LC_MESSAGES/graph.po
@@ -2,10 +2,10 @@
 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
 # This file is distributed under the same license as the PACKAGE package.
 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
-# 
+#
 # Translators:
 # Alex <hostspepc@gmail.com>, 2024
-# 
+#
 #, fuzzy
 msgid ""
 msgstr ""
diff --git a/services/graph/pkg/service/v0/l10n/locale/ru_RU/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ru_RU/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/ru_RU/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/ru_RU/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/sq/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/sq/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/sq/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/sq/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/tr/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/tr/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/tr/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/tr/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/vi/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/vi/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/vi/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/vi/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/zh/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/zh/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/zh/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/zh/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/service/v0/l10n/locale/zh_HK/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/zh_HK/LC_MESSAGES/graph.po
similarity index 100%
rename from services/graph/pkg/service/v0/l10n/locale/zh_HK/LC_MESSAGES/graph.po
rename to services/graph/pkg/l10n/locale/zh_HK/LC_MESSAGES/graph.po
diff --git a/services/graph/pkg/l10n/translation.go b/services/graph/pkg/l10n/translation.go
new file mode 100644
index 00000000000..d2f7c0994d9
--- /dev/null
+++ b/services/graph/pkg/l10n/translation.go
@@ -0,0 +1,32 @@
+package l10n
+
+import (
+	"embed"
+
+	"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
+)
+
+var (
+	//go:embed locale
+	_localeFS embed.FS
+)
+
+const (
+	// subfolder where the translation files are stored
+	_localeSubPath = "locale"
+
+	// domain of the graph service (transifex)
+	_domain = "graph"
+)
+
+// Translate translates a string based on the locale and default locale
+func Translate(content, locale, defaultLocale string) string {
+	t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath)
+	return t.Translate(content, locale)
+}
+
+// TranslateEntity returns a function that translates a struct or slice based on the locale
+func TranslateEntity(locale, defaultLocale string, entity any, opts ...l10n.TranslateOption) error {
+	t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath)
+	return t.TranslateEntity(locale, entity, opts...)
+}
diff --git a/services/graph/pkg/service/v0/api_driveitem_permissions.go b/services/graph/pkg/service/v0/api_driveitem_permissions.go
index 4c169b1d283..cca9e35656c 100644
--- a/services/graph/pkg/service/v0/api_driveitem_permissions.go
+++ b/services/graph/pkg/service/v0/api_driveitem_permissions.go
@@ -25,6 +25,8 @@ import (
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/render"
 	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
+	l10n_pkg "github.com/owncloud/ocis/v2/services/graph/pkg/l10n"
 
 	"github.com/owncloud/ocis/v2/ocis-pkg/conversions"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
@@ -632,6 +634,20 @@ func (api DriveItemPermissionsApi) ListPermissions(w http.ResponseWriter, r *htt
 		return
 	}
 
+	loc := r.Header.Get(l10n.HeaderAcceptLanguage)
+	w.Header().Add("Content-Language", loc)
+	if loc != "" && loc != "en" {
+		err := l10n_pkg.TranslateEntity(loc, "en", permissions,
+			l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
+				l10n.TranslateField("Description"),
+				l10n.TranslateField("DisplayName"),
+			),
+		)
+		if err != nil {
+			api.logger.Error().Err(err).Msg("tranlation error")
+		}
+	}
+
 	render.Status(r, http.StatusOK)
 	render.JSON(w, r, permissions)
 }
@@ -653,6 +669,20 @@ func (api DriveItemPermissionsApi) ListSpaceRootPermissions(w http.ResponseWrite
 		return
 	}
 
+	loc := r.Header.Get(l10n.HeaderAcceptLanguage)
+	w.Header().Add("Content-Language", loc)
+	if loc != "" && loc != "en" {
+		err := l10n_pkg.TranslateEntity(loc, "en", permissions,
+			l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
+				l10n.TranslateField("Description"),
+				l10n.TranslateField("DisplayName"),
+			),
+		)
+		if err != nil {
+			api.logger.Error().Err(err).Msg("tranlation error")
+		}
+	}
+
 	render.Status(r, http.StatusOK)
 	render.JSON(w, r, permissions)
 }
diff --git a/services/graph/pkg/service/v0/spacetemplates.go b/services/graph/pkg/service/v0/spacetemplates.go
index 1a7e0ea0faa..75da3a72863 100644
--- a/services/graph/pkg/service/v0/spacetemplates.go
+++ b/services/graph/pkg/service/v0/spacetemplates.go
@@ -15,18 +15,13 @@ import (
 	"github.com/cs3org/reva/v2/pkg/storagespace"
 	"github.com/cs3org/reva/v2/pkg/utils"
 	"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
+	l10n_pkg "github.com/owncloud/ocis/v2/services/graph/pkg/l10n"
 )
 
 var (
 	//go:embed spacetemplate/*
 	_spaceTemplateFS embed.FS
 
-	//go:embed l10n/locale
-	_localeFS embed.FS
-
-	// subfolder where the translation files are stored
-	_localeSubPath = "l10n/locale"
-
 	// name of the secret space folder
 	_spaceFolderName = ".space"
 
@@ -39,9 +34,6 @@ var (
 	// name of the readme.md file
 	_readmeName = "readme.md"
 
-	// domain of the graph service (transifex)
-	_domain = "graph"
-
 	// HeaderAcceptLanguage is the header key for the accept-language header
 	HeaderAcceptLanguage = "Accept-Language"
 
@@ -121,10 +113,9 @@ func imageUpload(ctx context.Context, mdc *metadata.CS3) (string, error) {
 }
 
 func readmeUpload(ctx context.Context, mdc *metadata.CS3, locale string, defaultLocale string) (string, error) {
-	t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath)
 	res, err := mdc.Upload(ctx, metadata.UploadRequest{
 		Path:    filepath.Join(_spaceFolderName, _readmeName),
-		Content: []byte(t.Translate(_readmeText, locale)),
+		Content: []byte(l10n_pkg.Translate(_readmeText, locale, defaultLocale)),
 	})
 	if err != nil {
 		return "", err
diff --git a/services/graph/pkg/unifiedrole/unifiedrole.go b/services/graph/pkg/unifiedrole/unifiedrole.go
index 4a6d5123ddc..4c373b532cc 100644
--- a/services/graph/pkg/unifiedrole/unifiedrole.go
+++ b/services/graph/pkg/unifiedrole/unifiedrole.go
@@ -7,6 +7,7 @@ import (
 
 	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
 	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
 	"google.golang.org/protobuf/proto"
 
 	"github.com/cs3org/reva/v2/pkg/conversions"
@@ -71,12 +72,54 @@ var legacyNames map[string]string = map[string]string{
 	UnifiedRoleSecureViewerID: conversions.RoleSecureViewer,
 }
 
+var (
+	// UnifiedRole Viewer, Role Description (resolves directly)
+	_viewerUnifiedRoleDescription = l10n.Template("View and download.")
+	// UnifiedRole Viewer, Role DisplayName (resolves directly)
+	_viewerUnifiedRoleDisplayName = l10n.Template("Can view")
+
+	// UnifiedRole SpaceViewer, Role Description (resolves directly)
+	_spaceViewerUnifiedRoleDescription = l10n.Template("View and download.")
+	// UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
+	_spaceViewerUnifiedRoleDisplayName = l10n.Template("Can view")
+
+	// UnifiedRole Editor, Role Description (resolves directly)
+	_editorUnifiedRoleDescription = l10n.Template("View, download, upload, edit, add and delete.")
+	// UnifiedRole Editor, Role DisplayName (resolves directly)
+	_editorUnifiedRoleDisplayName = l10n.Template("Can edit")
+
+	// UnifiedRole SpaseEditor, Role Description (resolves directly)
+	_spaceEditorUnifiedRoleDescription = l10n.Template("View, download, upload, edit, add and delete.")
+	// UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
+	_spaceEditorUnifiedRoleDisplayName = l10n.Template("Can edit")
+
+	// UnifiedRole FileEditor, Role Description (resolves directly)
+	_fileEditorUnifiedRoleDescription = l10n.Template("View, download and edit.")
+	// UnifiedRole FileEditor, Role DisplayName (resolves directly)
+	_fileEditorUnifiedRoleDisplayName = l10n.Template("Can edit")
+
+	// UnifiedRole EditorLite, Role Description (resolves directly)
+	_editorLiteUnifiedRoleDescription = l10n.Template("View, download and upload.")
+	// UnifiedRole EditorLite, Role DisplayName (resolves directly)
+	_editorLiteUnifiedRoleDisplayName = l10n.Template("Can upload")
+
+	// UnifiedRole Manager, Role Description (resolves directly)
+	_managerUnifiedRoleDescription = l10n.Template("View, download, upload, edit, add, delete and manage members.")
+	// UnifiedRole Manager, Role DisplayName (resolves directly)
+	_managerUnifiedRoleDisplayName = l10n.Template("Can manage")
+
+	// UnifiedRole SecureViewer, Role Description (resolves directly)
+	_secureViewerUnifiedRoleDescription = l10n.Template("View only documents, images and PDFs. Watermarks will be applied.")
+	// UnifiedRole SecureViewer, Role DisplayName (resolves directly)
+	_secureViewerUnifiedRoleDisplayName = l10n.Template("Can view (secure)")
+)
+
 // NewViewerUnifiedRole creates a viewer role.
 func NewViewerUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewViewerRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleViewerID),
-		Description: proto.String("View and download."),
+		Description: proto.String(_viewerUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -97,7 +140,7 @@ func NewSpaceViewerUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewSpaceViewerRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleSpaceViewerID),
-		Description: proto.String("View and download."),
+		Description: proto.String(_spaceViewerUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -114,7 +157,7 @@ func NewEditorUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewEditorRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleEditorID),
-		Description: proto.String("View, download, upload, edit, add and delete."),
+		Description: proto.String(_editorUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -131,7 +174,7 @@ func NewSpaceEditorUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewSpaceEditorRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleSpaceEditorID),
-		Description: proto.String("View, download, upload, edit, add and delete."),
+		Description: proto.String(_spaceEditorUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -148,7 +191,7 @@ func NewFileEditorUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewFileEditorRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleFileEditorID),
-		Description: proto.String("View, download and edit."),
+		Description: proto.String(_fileEditorUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -165,7 +208,7 @@ func NewEditorLiteUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewEditorLiteRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleEditorLiteID),
-		Description: proto.String("View, download and upload."),
+		Description: proto.String(_editorLiteUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -182,7 +225,7 @@ func NewManagerUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewManagerRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleManagerID),
-		Description: proto.String("View, download, upload, edit, add, delete and manage members."),
+		Description: proto.String(_managerUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -199,7 +242,7 @@ func NewSecureViewerUnifiedRole() *libregraph.UnifiedRoleDefinition {
 	r := conversions.NewSecureViewerRole()
 	return &libregraph.UnifiedRoleDefinition{
 		Id:          proto.String(UnifiedRoleSecureViewerID),
-		Description: proto.String("View only documents, images and PDFs. Watermarks will be applied."),
+		Description: proto.String(_secureViewerUnifiedRoleDescription),
 		DisplayName: displayName(r),
 		RolePermissions: []libregraph.UnifiedRolePermission{
 			{
@@ -482,27 +525,24 @@ func displayName(role *conversions.Role) *string {
 		return nil
 	}
 
-	// linter wants this to be a var
-	canEdit := "Can edit"
-
 	var displayName string
 	switch role.Name {
 	case conversions.RoleViewer:
-		displayName = "Can view"
+		displayName = _viewerUnifiedRoleDisplayName
 	case conversions.RoleSpaceViewer:
-		displayName = "Can view"
+		displayName = _spaceViewerUnifiedRoleDisplayName
 	case conversions.RoleEditor:
-		displayName = canEdit
+		displayName = _editorUnifiedRoleDisplayName
 	case conversions.RoleSpaceEditor:
-		displayName = canEdit
+		displayName = _spaceEditorUnifiedRoleDisplayName
 	case conversions.RoleFileEditor:
-		displayName = canEdit
+		displayName = _fileEditorUnifiedRoleDisplayName
 	case conversions.RoleEditorLite:
-		displayName = "Can upload"
+		displayName = _editorLiteUnifiedRoleDisplayName
 	case conversions.RoleManager:
-		displayName = "Can manage"
+		displayName = _managerUnifiedRoleDisplayName
 	case conversions.RoleSecureViewer:
-		displayName = "Can view (secure)"
+		displayName = _secureViewerUnifiedRoleDisplayName
 	default:
 		return nil
 	}
diff --git a/services/idp/pkg/service/v0/service.go b/services/idp/pkg/service/v0/service.go
index e0eeea46066..19c3603cdb6 100644
--- a/services/idp/pkg/service/v0/service.go
+++ b/services/idp/pkg/service/v0/service.go
@@ -48,7 +48,7 @@ func NewService(opts ...Option) Service {
 
 	if err := createTemporaryClientsConfig(
 		options.Config.IDP.IdentifierRegistrationConf,
-		options.Config.IDP.Iss,
+		options.Config.Commons.OcisURL,
 		options.Config.Clients,
 	); err != nil {
 		logger.Fatal().Err(err).Msg("could not create default config")
diff --git a/services/notifications/pkg/email/l10n/locale/ru/LC_MESSAGES/notifications.po b/services/notifications/pkg/email/l10n/locale/ru/LC_MESSAGES/notifications.po
index 325640f0ef5..d3e9a26d8bc 100644
--- a/services/notifications/pkg/email/l10n/locale/ru/LC_MESSAGES/notifications.po
+++ b/services/notifications/pkg/email/l10n/locale/ru/LC_MESSAGES/notifications.po
@@ -13,7 +13,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: EMAIL\n"
-"POT-Creation-Date: 2024-07-21 00:05+0000\n"
+"POT-Creation-Date: 2024-08-11 00:04+0000\n"
 "PO-Revision-Date: 2023-04-19 11:11+0000\n"
 "Last-Translator: dutty5, 2024\n"
 "Language-Team: Russian (https://app.transifex.com/owncloud-org/teams/6149/ru/)\n"
diff --git a/services/notifications/pkg/email/l10n/locale/ru_RU/LC_MESSAGES/notifications.po b/services/notifications/pkg/email/l10n/locale/ru_RU/LC_MESSAGES/notifications.po
index d0a75277098..7ce9d94a70e 100644
--- a/services/notifications/pkg/email/l10n/locale/ru_RU/LC_MESSAGES/notifications.po
+++ b/services/notifications/pkg/email/l10n/locale/ru_RU/LC_MESSAGES/notifications.po
@@ -12,7 +12,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: EMAIL\n"
-"POT-Creation-Date: 2024-07-21 00:05+0000\n"
+"POT-Creation-Date: 2024-08-11 00:04+0000\n"
 "PO-Revision-Date: 2023-04-19 11:11+0000\n"
 "Last-Translator: dutty5, 2024\n"
 "Language-Team: Russian (Russia) (https://app.transifex.com/owncloud-org/teams/6149/ru_RU/)\n"
diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go
index d37c83ac9e3..40253045e58 100644
--- a/services/proxy/pkg/config/defaults/defaultconfig.go
+++ b/services/proxy/pkg/config/defaults/defaultconfig.go
@@ -258,6 +258,10 @@ func DefaultPolicies() []config.Policy {
 					Endpoint: "/api/v0/settings",
 					Service:  "com.owncloud.web.settings",
 				},
+				{
+					Endpoint: "/auth-app/tokens",
+					Service:  "com.owncloud.web.auth-app",
+				},
 			},
 		},
 	}
diff --git a/services/userlog/pkg/service/l10n/locale/ru/LC_MESSAGES/userlog.po b/services/userlog/pkg/service/l10n/locale/ru/LC_MESSAGES/userlog.po
index 33ea8ce8d6f..6efcd81a4cb 100644
--- a/services/userlog/pkg/service/l10n/locale/ru/LC_MESSAGES/userlog.po
+++ b/services/userlog/pkg/service/l10n/locale/ru/LC_MESSAGES/userlog.po
@@ -14,7 +14,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: EMAIL\n"
-"POT-Creation-Date: 2024-07-21 00:05+0000\n"
+"POT-Creation-Date: 2024-08-11 00:04+0000\n"
 "PO-Revision-Date: 2023-03-15 08:28+0000\n"
 "Last-Translator: dutty5, 2024\n"
 "Language-Team: Russian (https://app.transifex.com/owncloud-org/teams/6149/ru/)\n"
diff --git a/services/userlog/pkg/service/l10n/locale/ru_RU/LC_MESSAGES/userlog.po b/services/userlog/pkg/service/l10n/locale/ru_RU/LC_MESSAGES/userlog.po
index 388b8b01382..6e6e9a48e58 100644
--- a/services/userlog/pkg/service/l10n/locale/ru_RU/LC_MESSAGES/userlog.po
+++ b/services/userlog/pkg/service/l10n/locale/ru_RU/LC_MESSAGES/userlog.po
@@ -12,7 +12,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: EMAIL\n"
-"POT-Creation-Date: 2024-07-21 00:05+0000\n"
+"POT-Creation-Date: 2024-08-11 00:04+0000\n"
 "PO-Revision-Date: 2023-03-15 08:28+0000\n"
 "Last-Translator: dutty5, 2024\n"
 "Language-Team: Russian (Russia) (https://app.transifex.com/owncloud-org/teams/6149/ru_RU/)\n"
diff --git a/vendor/golang.org/x/oauth2/LICENSE b/vendor/golang.org/x/oauth2/LICENSE
index 6a66aea5eaf..2a7cf70da6e 100644
--- a/vendor/golang.org/x/oauth2/LICENSE
+++ b/vendor/golang.org/x/oauth2/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2009 The Go Authors. All rights reserved.
+Copyright 2009 The Go Authors.
 
 Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are
@@ -10,7 +10,7 @@ notice, this list of conditions and the following disclaimer.
 copyright notice, this list of conditions and the following disclaimer
 in the documentation and/or other materials provided with the
 distribution.
-   * Neither the name of Google Inc. nor the names of its
+   * Neither the name of Google LLC nor the names of its
 contributors may be used to endorse or promote products derived from
 this software without specific prior written permission.
 
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 83ffd5193d6..011a5800e68 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -2199,7 +2199,7 @@ golang.org/x/net/netutil
 golang.org/x/net/proxy
 golang.org/x/net/publicsuffix
 golang.org/x/net/trace
-# golang.org/x/oauth2 v0.21.0
+# golang.org/x/oauth2 v0.22.0
 ## explicit; go 1.18
 golang.org/x/oauth2
 golang.org/x/oauth2/internal


Test patch:
diff --git a/ocis-pkg/l10n/l10n_test.go b/ocis-pkg/l10n/l10n_test.go
new file mode 100644
index 00000000000..603177e5710
--- /dev/null
+++ b/ocis-pkg/l10n/l10n_test.go
@@ -0,0 +1,414 @@
+package l10n
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTranslateStruct(t *testing.T) {
+
+	type InnerStruct struct {
+		Description string
+		DisplayName *string
+	}
+
+	type TopLevelStruct struct {
+		Description string
+		DisplayName *string
+		SubStruct   *InnerStruct
+	}
+
+	type WrapperStruct struct {
+		Description string
+		StructList  []*InnerStruct
+	}
+
+	toStrPointer := func(str string) *string {
+		return &str
+	}
+
+	tests := []struct {
+		name     string
+		entity   any
+		args     []TranslateOption
+		expected any
+		wantErr  bool
+	}{
+		{
+			name: "top level slice of struct",
+			entity: []*InnerStruct{
+				{
+					Description: "inner 1",
+					DisplayName: toStrPointer("innerDisplayName 1"),
+				},
+				{
+					Description: "inner 2",
+					DisplayName: toStrPointer("innerDisplayName 2"),
+				},
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+			},
+			expected: []*InnerStruct{
+				{
+					Description: "new Inner 1",
+					DisplayName: toStrPointer("new InnerDisplayName 1"),
+				},
+				{
+					Description: "new Inner 2",
+					DisplayName: toStrPointer("new InnerDisplayName 2"),
+				},
+			},
+		},
+		{
+			name: "top level slice of string",
+			entity: []string{
+				"inner 1",
+				"inner 2",
+			},
+			expected: []string{
+				"new Inner 1",
+				"new Inner 2",
+			},
+		},
+		{
+			name: "top level slice of struct",
+			entity: []*TopLevelStruct{
+				{
+					Description: "inner 1",
+					DisplayName: toStrPointer("innerDisplayName 1"),
+					SubStruct: &InnerStruct{
+						Description: "inner",
+						DisplayName: toStrPointer("innerDisplayName"),
+					},
+				},
+				{
+					Description: "inner 2",
+					DisplayName: toStrPointer("innerDisplayName 2"),
+				},
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+				TranslateStruct("SubStruct",
+					TranslateField("Description"),
+					TranslateField("DisplayName"),
+				),
+			},
+			expected: []*TopLevelStruct{
+				{
+					Description: "new Inner 1",
+					DisplayName: toStrPointer("new InnerDisplayName 1"),
+					SubStruct: &InnerStruct{
+						Description: "new Inner",
+						DisplayName: toStrPointer("new InnerDisplayName"),
+					},
+				},
+				{
+					Description: "new Inner 2",
+					DisplayName: toStrPointer("new InnerDisplayName 2"),
+				},
+			},
+		},
+		{
+			name: "wrapped struct full",
+			entity: &WrapperStruct{
+				StructList: []*InnerStruct{
+					{
+						Description: "inner 1",
+						DisplayName: toStrPointer("innerDisplayName 1"),
+					},
+					{
+						Description: "inner 2",
+						DisplayName: toStrPointer("innerDisplayName 2"),
+					},
+				},
+			},
+			args: []TranslateOption{
+				TranslateEach("StructList",
+					TranslateField("Description"),
+					TranslateField("DisplayName"),
+				),
+			},
+			expected: &WrapperStruct{
+				StructList: []*InnerStruct{
+					{
+						Description: "new Inner 1",
+						DisplayName: toStrPointer("new InnerDisplayName 1"),
+					},
+					{
+						Description: "new Inner 2",
+						DisplayName: toStrPointer("new InnerDisplayName 2"),
+					},
+				},
+			},
+		},
+		{
+			name:   "empty struct, NotExistingSubStructName",
+			entity: &TopLevelStruct{},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+				TranslateStruct("NotExistingSubStructName",
+					TranslateField("Description"),
+					TranslateField("DisplayName"),
+				),
+			},
+			expected: &TopLevelStruct{},
+		},
+		{
+			name:   "empty struct",
+			entity: &TopLevelStruct{},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+				TranslateStruct("SubStruct",
+					TranslateField("Description"),
+					TranslateField("DisplayName"),
+				),
+			},
+			expected: &TopLevelStruct{},
+		},
+		{
+			name: "empty struct, not existing field",
+			entity: &TopLevelStruct{
+				Description: "description",
+				DisplayName: toStrPointer("displayName"),
+			},
+			args: []TranslateOption{
+				TranslateField("NotExistingFieldName"),
+				TranslateStruct("SubStruct",
+					TranslateField("NotExistingFieldName"),
+				),
+			},
+			expected: &TopLevelStruct{
+				Description: "description",
+				DisplayName: toStrPointer("displayName"),
+			},
+		},
+		{
+			name: "inner struct DisplayName empy",
+			entity: &TopLevelStruct{
+				Description: "description",
+				DisplayName: toStrPointer("displayName"),
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+				TranslateStruct("SubStruct",
+					TranslateField("Description"),
+					TranslateField("DisplayName"),
+				),
+			},
+			expected: &TopLevelStruct{
+				Description: "new Description",
+				DisplayName: toStrPointer("new DisplayName"),
+			},
+		},
+		{
+			name: "inner struct full",
+			entity: &TopLevelStruct{
+				Description: "description",
+				DisplayName: toStrPointer("displayName"),
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+				TranslateStruct("SubStruct",
+					TranslateField("Description"),
+					TranslateField("DisplayName"),
+				),
+			},
+			expected: &TopLevelStruct{
+				Description: "new Description",
+				DisplayName: toStrPointer("new DisplayName"),
+			},
+		},
+		{
+			name: "full struct",
+			entity: &TopLevelStruct{
+				Description: "description",
+				DisplayName: toStrPointer("displayName"),
+				SubStruct: &InnerStruct{
+					Description: "inner",
+					DisplayName: toStrPointer("innerDisplayName"),
+				},
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+				TranslateStruct("SubStruct",
+					TranslateField("Description"),
+					TranslateField("DisplayName"),
+				),
+			},
+			expected: &TopLevelStruct{
+				Description: "new Description",
+				DisplayName: toStrPointer("new DisplayName"),
+				SubStruct: &InnerStruct{
+					Description: "new Inner",
+					DisplayName: toStrPointer("new InnerDisplayName"),
+				},
+			},
+		},
+		{
+			name:    "nil",
+			wantErr: true,
+		},
+		{
+			name:    "empty slice",
+			wantErr: true,
+		},
+		{
+			name:     "string slice",
+			entity:   []string{"description", "inner"},
+			expected: []string{"new Description", "new Inner"},
+		},
+		{
+			name: "string map",
+			entity: map[string]string{
+				"entryOne": "description",
+				"entryTwo": "inner",
+			},
+			expected: map[string]string{
+				"entryOne": "new Description",
+				"entryTwo": "new Inner",
+			},
+		},
+		{
+			name: "pointer struct map",
+			entity: map[string]*InnerStruct{
+				"entryOne": {Description: "description", DisplayName: toStrPointer("displayName")},
+				"entryTwo": {Description: "inner", DisplayName: toStrPointer("innerDisplayName")},
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+			},
+			expected: map[string]*InnerStruct{
+				"entryOne": {Description: "new Description", DisplayName: toStrPointer("new DisplayName")},
+				"entryTwo": {Description: "new Inner", DisplayName: toStrPointer("new InnerDisplayName")},
+			},
+		},
+		/* FIXME: non pointer maps are currently not working
+		{
+			name: "struct map",
+			entity: map[string]InnerStruct{
+				"entryOne": {Description: "description", DisplayName: toStrPointer("displayName")},
+				"entryTwo": {Description: "inner", DisplayName: toStrPointer("innerDisplayName")},
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+			},
+			expected: map[string]InnerStruct{
+				"entryOne": {Description: "new Description", DisplayName: toStrPointer("new DisplayName")},
+				"entryTwo": {Description: "new Inner", DisplayName: toStrPointer("new InnerDisplayName")},
+			},
+		},
+		*/
+		{
+			name: "slice map",
+			entity: map[string][]string{
+				"entryOne": {"description", "inner"},
+				"entryTwo": {"inner 2", "innerDisplayName 2"},
+			},
+			expected: map[string][]string{
+				"entryOne": {"new Description", "new Inner"},
+				"entryTwo": {"new Inner 2", "new InnerDisplayName 2"},
+			},
+		},
+		{
+			name: "double slice",
+			entity: [][]string{
+				{"description", "inner"},
+				{"inner 2", "innerDisplayName 2"},
+			},
+			expected: [][]string{
+				{"new Description", "new Inner"},
+				{"new Inner 2", "new InnerDisplayName 2"},
+			},
+		},
+		{
+			name: "nested structs",
+			entity: [][]*InnerStruct{
+				{
+					&InnerStruct{Description: "description", DisplayName: toStrPointer("displayName")},
+					&InnerStruct{Description: "inner", DisplayName: toStrPointer("innerDisplayName")},
+				},
+				{
+					&InnerStruct{Description: "inner 2", DisplayName: toStrPointer("innerDisplayName 2")},
+				},
+			},
+			args: []TranslateOption{
+				TranslateField("Description"),
+				TranslateField("DisplayName"),
+			},
+			expected: [][]*InnerStruct{
+				{
+					&InnerStruct{Description: "new Description", DisplayName: toStrPointer("new DisplayName")},
+					&InnerStruct{Description: "new Inner", DisplayName: toStrPointer("new InnerDisplayName")},
+				},
+				{
+					&InnerStruct{Description: "new Inner 2", DisplayName: toStrPointer("new InnerDisplayName 2")},
+				},
+			},
+		},
+		{
+			name: "double mapslices",
+			entity: []map[string][]string{
+				{
+					"entryOne": {"inner 1", "innerDisplayName 1"},
+					"entryTwo": {"inner 2", "innerDisplayName 2"},
+				},
+				{
+					"entryOne": {"description", "displayName"},
+				},
+			},
+			expected: []map[string][]string{
+				{
+					"entryOne": {"new Inner 1", "new InnerDisplayName 1"},
+					"entryTwo": {"new Inner 2", "new InnerDisplayName 2"},
+				},
+				{
+					"entryOne": {"new Description", "new DisplayName"},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := TranslateEntity(mock(), tt.entity, tt.args...)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("TranslateEntity() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			assert.Equal(t, tt.expected, tt.entity)
+		})
+	}
+}
+
+func mock() func(string, ...interface{}) string {
+	return func(s string, i ...interface{}) string {
+		switch s {
+		case "description":
+			return "new Description"
+		case "displayName":
+			return "new DisplayName"
+		case "inner":
+			return "new Inner"
+		case "innerDisplayName":
+			return "new InnerDisplayName"
+		case "inner 1":
+			return "new Inner 1"
+		case "innerDisplayName 1":
+			return "new InnerDisplayName 1"
+		case "inner 2":
+			return "new Inner 2"
+		case "innerDisplayName 2":
+			return "new InnerDisplayName 2"
+		}
+		return s
+	}
+}
diff --git a/tests/acceptance/expected-failures-API-on-OCIS-storage.md b/tests/acceptance/expected-failures-API-on-OCIS-storage.md
index ebb2bfdb0cb..ec6878209a1 100644
--- a/tests/acceptance/expected-failures-API-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-API-on-OCIS-storage.md
@@ -8,13 +8,13 @@ Basic file management like up and download, move, copy, properties, trash, versi
 
 #### [COPY a folder to received share file/folder seems to work but no change in shares](https://github.com/owncloud/ocis/issues/6999)
 
-- [coreApiWebdavProperties/copyFile.feature:284](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L284)
-- [coreApiWebdavProperties/copyFile.feature:285](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L285)
-- [coreApiWebdavProperties/copyFile.feature:308](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L308)
-- [coreApiWebdavProperties/copyFile.feature:309](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L309)
-- [coreApiWebdavProperties/copyFile.feature:482](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L482)
-- [coreApiWebdavProperties/copyFile.feature:483](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L483)
+- [coreApiWebdavProperties/copyFile.feature:286](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L286)
+- [coreApiWebdavProperties/copyFile.feature:287](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L287)
+- [coreApiWebdavProperties/copyFile.feature:310](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L310)
+- [coreApiWebdavProperties/copyFile.feature:311](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L311)
 - [coreApiWebdavProperties/copyFile.feature:484](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L484)
+- [coreApiWebdavProperties/copyFile.feature:485](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L485)
+- [coreApiWebdavProperties/copyFile.feature:486](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L486)
 
 #### [Custom dav properties with namespaces are rendered incorrectly](https://github.com/owncloud/ocis/issues/2140)
 
@@ -174,11 +174,11 @@ And other missing implementation of favorites
 
 #### [copying the file inside Shares folder returns 412](https://github.com/owncloud/ocis/issues/3874)
 
-- [coreApiWebdavProperties/copyFile.feature:238](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L238)
-- [coreApiWebdavProperties/copyFile.feature:239](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L239)
-- [coreApiWebdavProperties/copyFile.feature:455](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L455)
-- [coreApiWebdavProperties/copyFile.feature:456](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L456)
+- [coreApiWebdavProperties/copyFile.feature:240](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L240)
+- [coreApiWebdavProperties/copyFile.feature:241](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L241)
 - [coreApiWebdavProperties/copyFile.feature:457](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L457)
+- [coreApiWebdavProperties/copyFile.feature:458](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L458)
+- [coreApiWebdavProperties/copyFile.feature:459](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L459)
 
 ### Won't fix
 
@@ -189,9 +189,9 @@ Not everything needs to be implemented for ocis. While the oc10 testsuite covers
 
 #### [Blacklist files extensions](https://github.com/owncloud/ocis/issues/2177)
 
-- [coreApiWebdavProperties/copyFile.feature:107](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L107)
-- [coreApiWebdavProperties/copyFile.feature:108](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L108)
 - [coreApiWebdavProperties/copyFile.feature:109](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L109)
+- [coreApiWebdavProperties/copyFile.feature:110](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L110)
+- [coreApiWebdavProperties/copyFile.feature:111](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L111)
 - [coreApiWebdavProperties/createFileFolder.feature:95](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/createFileFolder.feature#L95)
 - [coreApiWebdavProperties/createFileFolder.feature:96](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/createFileFolder.feature#L96)
 - [coreApiWebdavProperties/createFileFolder.feature:97](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/createFileFolder.feature#L97)
@@ -233,9 +233,9 @@ Not everything needs to be implemented for ocis. While the oc10 testsuite covers
 ### [COPY file/folder to same name is possible (but 500 code error for folder with spaces path)](https://github.com/owncloud/ocis/issues/8711)
 
 - [coreApiSharePublicLink2/copyFromPublicLink.feature:198](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiSharePublicLink2/copyFromPublicLink.feature#L198)
-- [coreApiWebdavProperties/copyFile.feature:979](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L979)
-- [coreApiWebdavProperties/copyFile.feature:980](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L980)
-- [coreApiWebdavProperties/copyFile.feature:981](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L981)
+- [coreApiWebdavProperties/copyFile.feature:1064](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L1064)
+- [coreApiWebdavProperties/copyFile.feature:1065](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L1065)
+- [coreApiWebdavProperties/copyFile.feature:1066](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature#L1066)
 
 Note: always have an empty line at the end of this file.
 The bash script that processes this file requires that the last line has a newline on the end.
diff --git a/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature b/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature
index bf630a931d1..7417c56b806 100644
--- a/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature
+++ b/tests/acceptance/features/coreApiWebdavProperties/copyFile.feature
@@ -54,19 +54,21 @@ Feature: copy file
     And user "Brian" has been created with default attributes and without skeleton files
     And user "Brian" has created folder "/testshare"
     And user "Brian" has sent the following resource share invitation:
-      | resource        | testshare |
-      | space           | Personal  |
-      | sharee          | Alice     |
-      | shareType       | user      |
-      | permissionsRole | Viewer    |
+      | resource        | testshare          |
+      | space           | Personal           |
+      | sharee          | Alice              |
+      | shareType       | user               |
+      | permissionsRole | <permissions-role> |
     And user "Alice" has a share "testshare" synced
     When user "Alice" copies file "/textfile0.txt" to "/Shares/testshare/textfile0.txt" using the WebDAV API
     Then the HTTP status code should be "403"
     And user "Alice" should not be able to download file "/Shares/testshare/textfile0.txt"
     Examples:
-      | dav-path-version |
-      | old              |
-      | new              |
+      | dav-path-version | permissions-role |
+      | old              | Viewer           |
+      | new              | Viewer           |
+      | old              | Secure viewer    |
+      | new              | Secure viewer    |
 
   @skipOnReva
   Scenario Outline: copying a file to overwrite a file into a folder with no permissions
@@ -735,6 +737,48 @@ Feature: copy file
       | old              |
       | new              |
 
+  @skipOnReva
+  Scenario Outline: sharee copies a file from a shared folder, shared with  viewer permission
+    Given using <dav-path-version> DAV path
+    And user "Brian" has been created with default attributes and without skeleton files
+    And user "Brian" has created folder "/testshare"
+    And user "Brian" has uploaded file with content "hello world" to "testshare/fileInsideShare.txt"
+    And user "Brian" has sent the following resource share invitation:
+      | resource        | testshare |
+      | space           | Personal  |
+      | sharee          | Alice     |
+      | shareType       | user      |
+      | permissionsRole | Viewer    |
+    And user "Alice" has a share "testshare" synced
+    When user "Alice" copies file "/Shares/testshare/fileInsideShare.txt" to "/fileInsideShare.txt" using the WebDAV API
+    Then the HTTP status code should be "201"
+    And as "Alice" file "/fileInsideShare.txt" should exist
+    Examples:
+      | dav-path-version |
+      | old              |
+      | new              |
+
+  @skipOnReva
+  Scenario Outline: sharee copies a file from a shared folder, shared with secure viewer permission
+    Given using <dav-path-version> DAV path
+    And user "Brian" has been created with default attributes and without skeleton files
+    And user "Brian" has created folder "/testshare"
+    And user "Brian" has uploaded file with content "hello world" to "testshare/fileInsideShare.txt"
+    And user "Brian" has sent the following resource share invitation:
+      | resource        | testshare     |
+      | space           | Personal      |
+      | sharee          | Alice         |
+      | shareType       | user          |
+      | permissionsRole | Secure viewer |
+    And user "Alice" has a share "testshare" synced
+    When user "Alice" copies file "/Shares/testshare/fileInsideShare.txt" to "/fileInsideShare.txt" using the WebDAV API
+    Then the HTTP status code should be "403"
+    And as "Alice" file "/fileInsideShare.txt" should not exist
+    Examples:
+      | dav-path-version |
+      | old              |
+      | new              |
+
   @skipOnReva
   Scenario Outline: copying a file out of a shared folder as the sharer
     Given using <dav-path-version> DAV path
@@ -814,6 +858,47 @@ Feature: copy file
       | old              |
       | new              |
 
+  @skipOnReva
+  Scenario Outline: copying a file between shares received from different users when one share is shared via Viewer and Secure viewer permission
+    Given using <dav-path-version> DAV path
+    And user "Brian" has been created with default attributes and without skeleton files
+    And user "Carol" has been created with default attributes and without skeleton files
+    And user "Brian" has created folder "/testshare0"
+    And user "Brian" has uploaded file with content "content inside testshare0" to "/testshare0/testshare0.txt"
+    And user "Brian" has created folder "/testshare0/folder_to_copy/"
+    And user "Carol" has created folder "/testshare1"
+    And user "Brian" has sent the following resource share invitation:
+      | resource        | testshare0           |
+      | space           | Personal             |
+      | sharee          | Alice                |
+      | shareType       | user                 |
+      | permissionsRole | <permissions-role-1> |
+    And user "Alice" has a share "testshare0" synced
+    And user "Carol" has sent the following resource share invitation:
+      | resource        | testshare1           |
+      | space           | Personal             |
+      | sharee          | Alice                |
+      | shareType       | user                 |
+      | permissionsRole | <permissions-role-2> |
+    And user "Alice" has a share "testshare1" synced
+    When user "Alice" copies folder "/Shares/testshare0/folder_to_copy/" to "/Shares/testshare1/folder_to_copy/" using the WebDAV API
+    Then the HTTP status code should be "403"
+    And as "Alice" folder "/Shares/testshare1/folder_to_copy/" should not exist
+    When user "Alice" copies file "/Shares/testshare0/testshare0.txt" to "/Shares/testshare1/testshare0.txt" using the WebDAV API
+    Then the HTTP status code should be "403"
+    And as "Alice" file "/Shares/testshare1/testshare0.txt" should not exist
+
+    Examples:
+      | dav-path-version | permissions-role-1 | permissions-role-2 |
+      | old              | Secure viewer      | Secure viewer      |
+      | new              | Secure viewer      | Secure viewer      |
+      | old              | Secure viewer      | Viewer             |
+      | new              | Secure viewer      | Viewer             |
+      | old              | Editor             | Secure viewer      |
+      | new              | Editor             | Secure viewer      |
+      | old              | Viewer             | Secure viewer      |
+      | new              | Viewer             | Secure viewer      |
+
   @skipOnReva
   Scenario Outline: copying a folder between shares received from different users
     Given using <dav-path-version> DAV path
@@ -979,26 +1064,3 @@ Feature: copy file
       | old              |
       | new              |
       | spaces           |
-
-  @issue-9482
-  Scenario Outline: try to copy a file from shares space with secure viewer role to personal space
-    Given using <dav-path-version> DAV path
-    And user "Brian" has been created with default attributes and without skeleton files
-    And the administrator has assigned the role "Space Admin" to user "Brian" using the Graph API
-    And user "Brian" has created folder "/testshare"
-    And user "Brian" has uploaded file with content "testshare content" to "/testshare/testshare.txt"
-    And user "Brian" has sent the following resource share invitation:
-      | resource        | testshare     |
-      | space           | Personal      |
-      | sharee          | Alice         |
-      | shareType       | user          |
-      | permissionsRole | Secure viewer |
-    And user "Alice" has a share "testshare" synced
-    When user "Alice" copies file "/Shares/testshare/testshare.txt" to "/testshare.txt" using the WebDAV API
-    Then the HTTP status code should be "403"
-    And for user "Alice" the space "Personal" should not contain these entries:
-      | /testshare.txt |
-    Examples:
-      | dav-path-version |
-      | old              |
-      | new              |