95 Commits
Author SHA1 Message Date
raucao 0c667795a7 0.11.0
CI / Test (push) Successful in 33s
2026-08-10 14:43:42 -06:00
greg 3517303d37 Merge pull request 'Fix after-sign-in redirect issues' (#243) from bugfix/after-signin_redirects into master
CI / Test (push) Successful in 32s
Reviewed-on: #243
Reviewed-by: Greg <greg@kosmos.org>
2026-08-10 17:43:39 +00:00
raucao 89184cd851 Redirect to root after password reset
CI / Test (pull_request) Successful in 32s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2026-08-10 11:22:03 -06:00
raucao 29a9dee1cb Consume user_return_to on sign-in 2026-08-10 11:21:59 -06:00
raucao a62d84e20a Validate rs/oauth redirect_uri before storing return URL 2026-08-10 11:21:33 -06:00
raucao 7a892fad4e Add AGENTS.md
CI / Test (push) Successful in 32s
2026-08-10 11:01:21 -06:00
raucao d60d211765 Merge pull request 'Admin users page: filter by username' (#242) from feature/admin_user_search into master
CI / Test (push) Successful in 34s
Reviewed-on: #242
2026-08-10 16:40:32 +00:00
raucao d58c995230 Admin users page: filter by username
CI / Test (pull_request) Successful in 33s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
Allow admins to filter the user list/index by username (partial matches
allowed)
2026-08-10 10:36:05 -06:00
raucao 9c143bc8d6 Merge pull request 'Switch from Yarn to Bun; use Ruby 3.3.12' (#241) from dev/yarn_to_bun into master
CI / Test (push) Successful in 33s
Reviewed-on: #241
2026-08-10 00:50:19 +00:00
raucao 0d476c8a8b Re-add Node.js for CI actions
CI / Test (pull_request) Successful in 34s
Release Drafter / Update release notes draft (pull_request) Successful in 3s
2026-08-09 18:24:28 -06:00
raucao 31341fb4ce Upgrade Ruby to 3.3.12 everywhere
CI / Test (pull_request) Failing after 2s
2026-08-09 18:13:31 -06:00
raucao 39d9f9b312 Migrate everything from Yarn to Bun
No node.js or npm necessary anymore
2026-08-09 18:13:26 -06:00
raucao 8a6e733316 Merge pull request 'Show links for log-in, ToS, and Privacy Policy when logged out' (#240) from chore/login_links into master
CI / Test (push) Successful in 44s
Reviewed-on: #240
2026-08-09 20:13:39 +00:00
raucao 9f25b23e2e Show ToS and Privacy Policy links when logged out
CI / Test (pull_request) Successful in 41s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2026-08-09 14:10:25 -06:00
raucao 6a32dd5ee9 Show log-in link in header when logged out
Except on the login page itself
2026-08-09 14:10:00 -06:00
raucao b93ddd9dbd Merge pull request 'Add additional styles for rich text' (#239) from feature/rich-text into master
CI / Test (push) Successful in 41s
Reviewed-on: #239
2026-08-09 19:05:33 +00:00
raucao 7e7892df7a Fail CI if CSS wasn't built for any reason
CI / Test (pull_request) Successful in 40s
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2026-08-09 13:03:02 -06:00
raucao aff9e534c4 Upgrade Tailwind CSS 2026-08-09 13:02:27 -06:00
raucao 5f6269204b Add auth token config for Release Drafter 2026-08-09 11:54:35 -06:00
raucao b44b689caf Additional styles for rich text
CI / Test (pull_request) Failing after 40s
Especially for editable content
2026-08-09 11:42:41 -06:00
greg 55df8a9c31 Merge pull request 'Migrate from Drone CI to Gitea Actions' (#238) from dev/gitea_actions into master
CI / Test (push) Successful in 39s
Reviewed-on: #238
Reviewed-by: Greg <greg@kosmos.org>
2026-08-08 19:52:03 +00:00
raucao da69cb4ad0 Cache gems in CI
CI / Test (pull_request) Successful in 41s
Release Drafter / Update release notes draft (pull_request) Failing after 2s
2026-08-08 08:36:39 -06:00
raucao 139d543e4f Remove bun from script
CI / Test (pull_request) Successful in 2m11s
2026-08-08 08:15:24 -06:00
raucao 744cc7d7dd Migrate from Drone CI to Gitea Actions
CI / Test (pull_request) Failing after 2m14s
2026-08-08 08:08:57 -06:00
raucao 8f1fadc74a Point tailwind command at module executable
continuous-integration/drone/push Build is failing
2026-08-07 12:38:09 -06:00
raucao e7527a1def Merge pull request 'Allow anyone to publish NIP-46 events' (#236) from feature/nostr_allow_public_bunker_events into master
continuous-integration/drone/push Build is passing
Reviewed-on: #236
2026-08-07 18:29:42 +00:00
raucao 0d248ead04 Allow anyone to publish NIP-46 events
Release Drafter / Update release notes draft (pull_request) Failing after 3s
This turns our relay into a public remote-sign-in relay
2026-08-07 11:03:31 -06:00
raucao 3bd07472b2 Fix pages views when signed out
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2025-06-12 16:09:42 +04:00
raucao 32b1c2748a Fix wrong variable
continuous-integration/drone/push Build is passing
2025-05-30 20:22:04 +04:00
raucao fc6cac8368 Remove superfluous link
continuous-integration/drone/push Build is passing
Already linked in the same paragraph
2025-05-30 16:53:05 +04:00
greg eefdc88a47 Merge pull request 'Editable content' (#229) from feature/186-content_editing into master
continuous-integration/drone/push Build is passing
Reviewed-on: #229
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-30 11:14:50 +00:00
raucao f2e8ca790c Add Privacy and ToS pages, footer menu
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 3s
2025-05-30 13:27:15 +04:00
raucao 32cd4d896d Fix link color for Devise links
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-29 17:26:18 +04:00
raucao 67c450860a Fix tab links
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-29 16:24:33 +04:00
raucao f1d9cf1e3d Remove special link class
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
This cleans up the code quite a bit, but also allows links in editable
content to be rendered with the default style.
2025-05-29 16:10:34 +04:00
raucao ab1490f472 Remove Kosmos name from wording
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
refs #222
2025-05-29 14:24:43 +04:00
raucao 6014134396 Finish MVP for content editing 2025-05-29 14:18:14 +04:00
raucao 6713665a61 WIP Rename "projects" page, make content editable
continuous-integration/drone/push Build is failing
2025-05-28 18:42:10 +04:00
raucao 315cf4dd9f Add editable content helpers 2025-05-28 18:41:53 +04:00
raucao 2f86b3c16f Add admin/editable_contents controller 2025-05-28 18:40:54 +04:00
raucao 55c63be9e2 Memoize instance variable 2025-05-28 18:39:48 +04:00
raucao 5c8ffc2630 Add editable contents table 2025-05-28 18:39:25 +04:00
raucao c7a21c7a69 Add top margin to h3 within content 2025-05-28 18:37:59 +04:00
raucao 252b0f1792 Revert "Add ActionText configs, update spec helpers/configs"
This reverts commit c9d23f829d.
2025-05-28 16:53:31 +04:00
raucao 57246ea76d Fix navbar current link 2025-05-28 15:35:57 +04:00
raucao c9d23f829d Add ActionText configs, update spec helpers/configs 2025-05-28 14:52:31 +04:00
raucao 55111f1b8b Allow using icons without custom class
continuous-integration/drone/push Build is passing
2025-05-28 14:50:59 +04:00
raucao 4c6e64095f Fix unused invitations count
continuous-integration/drone/push Build is failing
2025-05-28 14:28:59 +04:00
raucao 450ccff65b Add custom class to all remaining icons
continuous-integration/drone/push Build is failing
2025-05-28 13:57:01 +04:00
greg 0778f29a8e Merge pull request 'Refactor ejabberd API integration' (#226) from core/refactor_ejabberd_integration into master
continuous-integration/drone/push Build is passing
Reviewed-on: #226
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-28 09:22:39 +00:00
greg 3dbde86cdf Merge pull request 'Introduce membership statuses' (#227) from feature/contributor_status into master
continuous-integration/drone/push Build is passing
Reviewed-on: #227
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-28 09:16:02 +00:00
raucao 0dcfefd66c Merge pull request 'Improve admin pages for invitations' (#228) from feature/admin_invitations into feature/contributor_status
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
Reviewed-on: #228
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-28 09:00:11 +00:00
raucao c6a187b25a Limit invitees on admin user page, link to invitations for more
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2025-05-28 12:50:10 +04:00
raucao c99d8545c1 Add username filter to admin invitations index
continuous-integration/drone/push Build is passing
2025-05-28 12:34:52 +04:00
raucao e8f912360b Fix wrong stats number
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-28 12:11:26 +04:00
raucao c94a0e34d1 Add donations to user details, link to filtered list
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-27 19:04:35 +04:00
raucao 04094efbdb Add username filter with UI to admin donations page 2025-05-27 18:43:45 +04:00
raucao 71352d13d2 Add pending donations to admin donations index
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
And add more info to the details page
2025-05-27 18:08:22 +04:00
raucao fff7527694 Don't show njump link when no pubkey set 2025-05-27 17:35:48 +04:00
raucao 7a8ca0707a Add missing dash for no member status
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-27 17:18:47 +04:00
raucao b657a25d4d Wording 2025-05-27 17:16:26 +04:00
raucao e48132cf5f Set member status to sustainer upon payment
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Introduces a state machine for the payment status as well.

refs #213
2025-05-27 16:39:03 +04:00
raucao 463bf34cdf Add link for icon library to README
continuous-integration/drone/push Build is passing
2025-05-27 15:12:31 +04:00
raucao f313686b13 Add settings for member statuses
continuous-integration/drone/push Build is passing
2025-05-27 14:59:10 +04:00
raucao 0b4bc4ef5c Improve color shade of sidebar link icon
Was a bit bright
2025-05-27 14:58:45 +04:00
raucao 393f85e45c WIP Add member/contributor status to users
continuous-integration/drone/push Build is passing
2025-05-27 13:32:58 +04:00
raucao d737d9f6b8 Refactor ejabberd API integration
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2025-05-26 14:10:27 +04:00
raucao 4bf6985b87 Fix wrong matcher for custom LDAP attribute
continuous-integration/drone/push Build is passing
389ds doesn't like case-insensitive matches for 7-bit ASCII strings
2025-05-23 14:08:41 +04:00
greg 308cac5a39 Merge pull request 'Add Mastodon API client, service for syncing avatars and display names' (#225) from feature/mastodon_api into master
continuous-integration/drone/push Build is passing
Reviewed-on: #225
Reviewed-by: Greg <greg@noreply.kosmos.org>
2025-05-23 08:48:15 +00:00
raucao 7f766473ab Fix typo
continuous-integration/drone/push Build is passing
2025-05-22 13:21:37 +04:00
raucao c1bac2625c Only log exception to stdout
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-05-21 16:42:49 +04:00
raucao c5c6765d67 Log LDAP exceptions
continuous-integration/drone/push Build is passing
2025-05-21 16:29:52 +04:00
raucao 171524fb83 Use production link
continuous-integration/drone/push Build is passing
2025-05-18 14:58:55 +04:00
raucao 3538067da6 Use production link
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 2s
2025-05-18 14:58:34 +04:00
raucao c374bcd3bc Merge branch 'master' into feature/mastodon_api
continuous-integration/drone/push Build is running
2025-05-18 14:56:42 +04:00
raucao 655009ad7a Add example link for PGP pubkey
continuous-integration/drone/push Build is running
2025-05-18 14:56:29 +04:00
raucao 71c9bd29ab Merge branch 'master' into feature/mastodon_api 2025-05-18 14:46:28 +04:00
raucao e66d134550 Log missing l param for WKD requests, return 400
continuous-integration/drone/push Build is passing
2025-05-18 14:46:04 +04:00
raucao 11167e3e43 Merge branch 'master' into feature/mastodon_api 2025-05-18 14:37:47 +04:00
raucao ebbd87368c Handle l param missing for WKD request
continuous-integration/drone/push Build is passing
2025-05-18 14:37:22 +04:00
raucao 7b0ebb761f Allow display name to be removed
continuous-integration/drone/push Build is passing
When form field is empty
2025-05-18 14:26:09 +04:00
raucao fb03427d59 Allow syncing a single Mastodon profile
continuous-integration/drone/push Build is passing
2025-05-17 18:56:34 +04:00
raucao ad138f715c Update doc 2025-05-17 18:56:34 +04:00
raucao 6730aae2dc Only update other avatars in one place
Prevent future mistakes
2025-05-17 18:56:33 +04:00
raucao a71aa3fda2 Don't queue job when service isn't enabled 2025-05-17 18:56:33 +04:00
raucao 92e6b1395a Add avatar to admin user page 2025-05-17 18:56:33 +04:00
raucao 37c59b7b0c Sync Mastodon IDs/profiles to local accounts
Add a new service to import some data from Mastodon accounts:

* Find users by username, store Mastodon account ID in local db when
  found
* Import display name (don't overwrite existing)
* Import avatar (don't overwrite existing)
2025-05-17 18:56:30 +04:00
raucao c291765777 Add mastodon_id to users 2025-05-17 16:44:13 +04:00
raucao f0cfde560b Add Mastodon API service class, auth token config
Add a new REST API service class to keep things DRY
2025-05-17 14:18:16 +04:00
raucao c43e43d89c Open RS apps in new tab
continuous-integration/drone/push Build is passing
2025-05-16 17:30:11 +04:00
raucao dbbf116c52 Fix RS storage-first auth work in dev, remove token
continuous-integration/drone/push Build is passing
See https://github.com/remotestorage/remotestorage.js/issues/900
2025-05-16 15:59:40 +04:00
raucao 208b1f04ae Fix web app icon component
continuous-integration/drone/push Build is passing
2025-05-16 15:38:03 +04:00
raucao 8049f81b73 Merge pull request 'Set XMPP avatar when new avatar is uploaded' (#224) from feature/ejabberd_pep into master
continuous-integration/drone/push Build is running
Reviewed-on: #224
2025-05-16 11:37:29 +00:00
raucao 5f276ff349 Queue XmppSetAvatarJob when new avatar is uploaded
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Failing after 10m9s
And let job do nothing in development for now
2025-05-15 22:04:25 +04:00
raucao 5916969447 Add job for setting avatar via XMPP 2025-05-15 20:05:53 +04:00
440 changed files with 2352 additions and 1805 deletions
-61
View File
@@ -1,61 +0,0 @@
---
kind: pipeline
type: docker
name: CI build
steps:
- name: restore-cache
image: drillster/drone-volume-cache
volumes:
- name: cache
path: /cache
settings:
restore: true
mount:
- ./vendor/cache
when:
branch:
- master
- name: rspec
image: gitea.kosmos.org/kosmos/akkounts-ci:0.9.1
environment:
RAILS_ENV: test
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
commands:
- bundle config unset deployment
- bundle config set cache_all 'true'
- bundle config set cache_path 'vendor/cache'
- bundle config set with 'development test'
- bundle install --jobs=3 --retry=3
- bundle exec rails db:create
- bundle exec rails db:migrate
- yarn install
- rake css:build
- bundle exec rspec
- name: rebuild-cache
image: drillster/drone-volume-cache
volumes:
- name: cache
path: /cache
settings:
rebuild: true
mount:
- ./vendor/cache
when:
branch:
- master
services:
- name: redis
image: redis
volumes:
- name: cache
host:
path: /var/lib/drone/tmp
---
kind: signature
hmac: f9a8cf97f6596625721365f6238f6f298aa5a7a4de10c3fb61c57202ae9d1ee1
...
+59
View File
@@ -0,0 +1,59 @@
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
test:
name: Test
runs-on: ubuntu-latest
container: gitea.kosmos.org/kosmos/akkounts-ci:0.11.1
services:
redis:
image: redis
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
RAILS_ENV: test
REDIS_URL: redis://redis:6379/0
RS_REDIS_URL: redis://redis:6379/1
steps:
- uses: actions/checkout@v4
- name: Restore bundle cache
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-ruby-3.3.12-${{ hashFiles('Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby-3.3.12-
- name: Install Ruby dependencies
run: |
bundle config unset deployment
bundle config set cache_all 'true'
bundle config set cache_path 'vendor/cache'
bundle config set path 'vendor/bundle'
bundle config set with 'development test'
bundle install --jobs=3 --retry=3
- name: Prepare database
run: |
bundle exec rails db:create
bundle exec rails db:migrate
- name: Install JS dependencies
run: bun install
- name: Build CSS
run: |
bundle exec rake css:build
test -s app/assets/builds/application.css
- name: Run tests
run: bundle exec rspec
+2
View File
@@ -9,3 +9,5 @@ jobs:
steps:
- name: Release Drafter
uses: https://github.com/raucao/gitea-release-drafter@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-4
View File
@@ -34,10 +34,6 @@
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
bun.lock
# Ignore local dotenv config file
.env
+1 -1
View File
@@ -1 +1 @@
3.3.0
3.3.12
+74
View File
@@ -0,0 +1,74 @@
# AGENTS.md
Akkounts is a Rails 8 monolith for managing Kosmos/LDAP user accounts.
It authenticates against an LDAP directory via Devise and integrates with
ejabberd, Discourse, Mastodon, remoteStorage, Nostr, LNDHub, and BTCPay.
## Development environment
Development runs in Docker Compose — run all commands against the `web` container.
Start services: `docker compose up` (web, ldap, redis, minio, liquor-cabinet, strfry).
The `web` service runs `bin/dev` (foreman: Puma + Tailwind CSS watcher) and embeds
Solid Queue workers (`SOLID_QUEUE_IN_PUMA=true`).
First-time LDAP setup (after creating the 389ds backend once):
`docker compose exec ldap dsconf localhost backend create --suffix="dc=kosmos,dc=org" --be_name="dev"`
then `docker compose run web bin/rails ldap:setup`.
## Common commands (prefix with `docker compose exec web`)
- `bin/rspec` — run the test suite (CI runs `bundle exec rspec`)
- `bin/rails db:prepare` — create/migrate DB (also `db:setup`, `db:migrate`)
- `bin/rails ldap:setup` — reset LDAP dir and seed dev entries
- `bin/rails css:build` — build Tailwind CSS (auto-watched in dev)
- `bin/rails lndhub:generate_wallets`, `invitations:generate_for_all_users[N]`
- `bin/jobs` — Solid Queue CLI
Tests use the test env explicitly: `docker compose exec -e RAILS_ENV=test web bin/rspec`.
There is no enforced linter — a `bin/rubocop` binstub exists but no `.rubocop.yml`
(rubocop is a transitive gem, not in the Gemfile); CI does not lint.
## Stack & conventions
- **Assets/views:** ERB + ViewComponent (`app/components/`), Tailwind built via Bun +
importmap (`config/importmap.rb`); pin JS deps with `bin/importmap pin <pkg> --download`.
- **Auth:** Devise + `devise_ldap_authenticatable`; `authentication_keys = [:cn]`,
custom SSHA512 password builder, admin bind. Admin-only mounts (`/jobs` MissionControl,
`/flipper` Flipper UI) are gated by `authenticate :user, ->(u) { u.is_admin? }`.
- **Services:** `app/services/` uses `ApplicationService.call(**args)` with namespaced
`*ManagerService` bases (e.g. `UserManagerService`) and verb-named operations
(`UserManager::CreateAccount`, `NostrManager::PublishEvent`, `BtcpayManager::*`).
- **Models:** `User` is central; `Setting` uses rails-settings-cached with one concern per
service in `app/models/concerns/settings/`. LNDHub models connect to a separate DB
(`LndhubBase` `establish_connection :lndhub`). LDAP is accessed via `LdapService`/
`LdapManagerService`, not ActiveRecord.
- **Jobs:** `app/jobs/` inherit `ApplicationJob < ActiveJob::Base`; backend is **Solid Queue**
(`config.active_job.queue_adapter = :solid_queue`, separate `queue` DB in prod).
`config/recurring.yml` has no active scheduled jobs. Ignore `config/sidekiq.yml` — it's
stale (sidekiq is not in the Gemfile; the README mention is outdated).
- **Config:** env-var driven (`.env.example`/`.env.development`/`.env.test`); `SERVICES`
constant is loaded from `config/services.yml` in `config/initializers/service_details.rb`.
Custom LDAP schema attributes are applied from `schemas/ldap/*.ldif` by `lib/tasks/ldap.rake`.
## Testing conventions (`spec/`)
`spec/rails_helper.rb` includes FactoryBot, Devise `ControllerHelpers` (controller specs),
Warden `Test::Helpers`, DatabaseCleaner (transactional fixtures **off**), ViewComponent
`TestHelpers` + Capybara matchers, and `ActiveJob::TestHelper` for `type: :job`.
- **Feature specs** (`spec/features/`, `type: :feature`): Capybara **rack-test** driver (no JS).
Log in with Warden: `login_as user, scope: :user`; stub admin with
`allow(Devise::LDAP::Adapter).to receive(:get_ldap_param).with(user.cn, :admin).and_return(["true"])`.
- **Services** (`spec/services/`) are tagged `type: :model`; exercise `#call`/`service.send(:private)`
and assert on DB rows / `enqueued_jobs`.
- **Request/component/mailer/job/model/helper** specs in matching `spec/` subdirs.
Fixtures live in `spec/fixtures/`; factories in `spec/factories/`.
- `spec_helper.rb` reloads routes before each `type: :controller` spec (Devise + Rails 8 workaround).
## Notes & gotchas
- Generators (`config/application.rb`): ERB templates, RSpec, no stylesheets, factory_bot
with `_factory` suffix dir `spec/factories`.
- `gitno/` and `extras/strfry/` are scratch/infra (Deno Nostr relay policies) — not app code; ignore.
- Don't add code comments unless asked (matches repo style); `frozen_string_literal` is inconsistent.
- Default dev login: username `admin` / password `admin is admin`.
+6 -7
View File
@@ -1,22 +1,21 @@
# syntax=docker/dockerfile:1
FROM ruby:3.3.4
FROM ruby:3.3.12
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl unzip \
ldap-utils tini libvips
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
RUN apt-get update && apt-get install -y nodejs
RUN curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.14
ENV PATH="/root/.bun/bin:$PATH"
WORKDIR /akkounts
COPY ["Gemfile", "Gemfile.lock", "package.json", "./"]
COPY ["Gemfile", "Gemfile.lock", "package.json", "bun.lock", "./"]
RUN bundle install
RUN gem install foreman
RUN npm install -g yarn
RUN yarn install
RUN bun install
ENTRYPOINT ["/usr/bin/tini", "--"]
EXPOSE 3000
+2
View File
@@ -32,6 +32,7 @@ gem 'devise_ldap_authenticatable'
gem 'net-ldap'
# Utilities
gem 'aasm'
gem "image_processing", "~> 1.12.2"
gem "rqrcode", "~> 2.0"
gem 'rails-settings-cached', '~> 2.8.3'
@@ -41,6 +42,7 @@ gem 'flipper-active_record'
gem 'flipper-ui'
gem 'gpgme', '~> 2.0.24'
gem 'zbase32', '~> 0.1.1'
gem 'kramdown'
# HTTP requests
gem 'faraday'
+4
View File
@@ -1,6 +1,8 @@
GEM
remote: https://rubygems.org/
specs:
aasm (5.5.0)
concurrent-ruby (~> 1.0)
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
@@ -526,6 +528,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
aasm
aws-sdk-s3
bcrypt (~> 3.1)
capybara
@@ -546,6 +549,7 @@ DEPENDENCIES
image_processing (~> 1.12.2)
importmap-rails
jbuilder (~> 2.7)
kramdown
letter_opener
letter_opener_web
listen (~> 3.2)
+1 -1
View File
@@ -1,2 +1,2 @@
web: bin/rails server -b 0.0.0.0 -p 3000
css: yarn build:css --watch
css: bun run build:css:tailwind --watch
+3 -2
View File
@@ -1,4 +1,4 @@
[![Build Status](https://drone.kosmos.org/api/badges/kosmos/akkounts/status.svg)](https://drone.kosmos.org/kosmos/akkounts)
[![Build Status](https://gitea.kosmos.org/kosmos/akkounts/actions/workflows/ci.yml/badge.svg)](https://gitea.kosmos.org/kosmos/akkounts/actions)
# Akkounts
@@ -36,7 +36,7 @@ run web`._
Installing dependencies:
bundle install
yarn install
bun install
Migrating the local database (after schema changes):
@@ -128,6 +128,7 @@ command:
### Front-end
* [Icons](https://feathericons.com)
* [Tailwind CSS](https://tailwindcss.com/)
* [Sass](https://sass-lang.com/documentation)
* [Stimulus](https://stimulus.hotwired.dev/handbook/)
@@ -7,7 +7,7 @@
@import "components/buttons";
@import "components/dashboard_services";
@import "components/forms";
@import "components/links";
@import "components/notifications";
@import "components/pagination";
@import "components/rich_text";
@import "components/tables";
@@ -6,6 +6,7 @@
body {
@apply leading-none bg-cover bg-fixed;
background-image: linear-gradient(35deg, rgba(255,0,255,0.2) 0, rgba(13,79,153,0.8) 100%), url('/img/bg-1.jpg');
color: black;
}
body#admin {
@@ -32,6 +33,10 @@
@apply pt-8 sm:pt-12;
}
main section h3:not(:first-child) {
@apply mt-8;
}
main section:first-of-type {
@apply pt-0;
}
@@ -55,4 +60,11 @@
main ul li {
@apply leading-6;
}
main a:not(nav > *) {
@apply text-blue-600;
&:hover { @apply underline; }
&:visited { @apply text-indigo-600; }
&:active { @apply text-red-600; }
}
}
+16 -3
View File
@@ -1,5 +1,15 @@
@layer components {
.btn-text-dark { @apply text-black; }
.btn-text-dark:hover { @apply text-black no-underline; }
.btn-text-dark:visited { @apply text-black; }
.btn-text-dark:active { @apply text-black; }
.btn-text-light { @apply text-white; }
.btn-text-light:hover { @apply text-white no-underline; }
.btn-text-light:visited { @apply text-white; }
.btn-text-light:active { @apply text-white; }
.btn {
@apply btn-text-dark;
@apply inline-block font-semibold rounded-md leading-none cursor-pointer text-center
transition-colors duration-75 focus:outline-none focus:ring-4;
}
@@ -28,17 +38,20 @@
}
.btn-blue {
@apply bg-blue-500 hover:bg-blue-600 text-white
@apply btn-text-light;
@apply bg-blue-500 hover:bg-blue-600
focus:ring-blue-400 focus:ring-opacity-75;
}
.btn-emerald {
@apply bg-emerald-500 hover:bg-emerald-600 text-white
@apply btn-text-light;
@apply bg-emerald-500 hover:bg-emerald-600
focus:ring-emerald-400 focus:ring-opacity-75;
}
.btn-red {
@apply bg-red-600 hover:bg-red-700 text-white
@apply btn-text-light;
@apply bg-red-600 hover:bg-red-700
focus:ring-red-500 focus:ring-opacity-75;
}
@@ -1,8 +0,0 @@
@layer components {
.ks-text-link {
@apply text-blue-600;
&:hover { @apply underline; }
&:visited { @apply text-indigo-600; }
&:active { @apply text-red-600; }
}
}
@@ -34,7 +34,7 @@
.pagy-nav .page a, .page.gap {
@apply bg-white border-gray-300 text-gray-500 hover:bg-gray-100 relative
inline-flex items-center border px-4 py-2 text-sm font-medium
focus:z-20;
no-underline focus:z-20;
}
.pagy-nav .page.active {
@@ -0,0 +1,30 @@
@layer base {
main {
.rich-text {
ul {
@apply list-disc list-inside;
li {
@apply my-2;
}
}
ol {
@apply list-decimal list-inside;
li {
@apply my-2;
}
}
li > ul,
li > ol {
@apply ps-6 my-1;
}
hr {
@apply my-8;
}
}
}
}
@@ -2,6 +2,8 @@
module AppCatalog
class WebAppIconComponent < ViewComponent::Base
include ApplicationHelper
def initialize(web_app:)
if web_app&.icon&.attached?
@image_url = image_url_for(web_app.icon)
@@ -1,4 +1,4 @@
<%= link_to @href, class: @class, data: {
<%= link_to @href, class: @class, target: @target, data: {
'dropdown-target': "menuItem",
'action': "keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent"
} do %>
+2 -1
View File
@@ -1,8 +1,9 @@
# frozen_string_literal: true
class DropdownLinkComponent < ViewComponent::Base
def initialize(href:, separator: false, add_class: nil)
def initialize(href:, open_in_new_tab: false, separator: false, add_class: nil)
@href = href
@target = open_in_new_tab ? "_blank" : nil
@class = class_str(separator, add_class)
end
@@ -0,0 +1,30 @@
<div class="inline-block text-left" data-controller="modal" data-action="keydown.esc->modal#close">
<button class="btn-md btn-outline text-red-600" data-action="click->modal#open" title="Edit">
<%= content || "Edit" %>
</button>
<%= render ModalComponent.new(show_close_button: false) do %>
<%= form_with model: [:admin, @editable_content],
html: { autocomplete: "off" } do |form| %>
<%= form.hidden_field :redirect_to, value: @redirect_to %>
<p class="mb-2">
<%= form.label :content, @editable_content.key.capitalize, class: 'font-bold' %>
</p>
<% if @editable_content.rich_text %>
<p>
<%= form.textarea :content, class: "md:w-[56rem] md:h-[28rem]" %>
</p>
<p class="text-right">
<%= form.submit "Save", class: "ml-2 btn-md btn-blue" %>
</p>
<% else %>
<p class="">
<%= form.text_field :content, class: "w-80" %>
</p>
<p>
<%= form.submit "Save", class: "btn-md btn-blue w-full" %>
</p>
<% end %>
<% end %>
<% end %>
</div>
@@ -0,0 +1,6 @@
class EditContentButtonComponent < ViewComponent::Base
def initialize(context:, key:, rich_text: false, redirect_to: nil)
@editable_content = EditableContent.find_or_create_by(context:, key:, rich_text:)
@redirect_to = redirect_to
end
end
@@ -0,0 +1,11 @@
<% if @editable_content.has_content? %>
<% if @editable_content.rich_text %>
<div class="rich-text">
<%= helpers.markdown_to_html @editable_content.content %>
</div>
<% else %>
<%= @editable_content.content %>
<% end %>
<% else %>
<%= @default %>
<% end %>
@@ -0,0 +1,6 @@
class EditableContentComponent < ViewComponent::Base
def initialize(context:, key:, rich_text: false, default: nil)
@editable_content = EditableContent.find_or_create_by(context:, key:, rich_text:)
@default = default
end
end
@@ -1,4 +1,4 @@
<main class="w-full max-w-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<main class="w-full max-w-xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
<%= content %>
</div>
@@ -1,4 +1,4 @@
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
<div class="md:min-h-[50vh] bg-white rounded-lg shadow px-6 sm:px-12 py-8 sm:py-12">
<%= content %>
</div>
@@ -1,4 +1,4 @@
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow">
<div class="md:min-h-[50vh] divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<aside class="py-6 sm:py-8 lg:col-span-3">
@@ -1,4 +1,4 @@
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
<main class="w-full max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
<div class="md:min-h-[50vh] bg-white rounded-lg shadow">
<div class="px-6 sm:px-12 pt-2 sm:pt-4">
<%= render partial: @tabnav_partial %>
+2 -1
View File
@@ -12,7 +12,8 @@
</div>
<%= render DropdownComponent.new do %>
<%= render DropdownLinkComponent.new(
href: launch_app_services_storage_rs_auth_url(@auth)
href: launch_app_services_storage_rs_auth_url(@auth),
open_in_new_tab: true
) do %>
Launch app
<% end %>
+1 -1
View File
@@ -29,7 +29,7 @@ class SidenavLinkComponent < ViewComponent::Base
def class_names_icon(path)
if @active
"text-teal-500 group-hover:text-teal-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
"text-teal-600 group-hover:text-teal-600 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
elsif @disabled
"text-gray-300 group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
else
+14 -3
View File
@@ -4,11 +4,22 @@ class Admin::DonationsController < Admin::BaseController
# GET /donations
def index
@pagy, @donations = pagy(Donation.completed.order('paid_at desc'))
@username = params[:username].presence
pending_scope = Donation.incomplete.joins(:user).order('paid_at desc')
completed_scope = Donation.completed.joins(:user).order('paid_at desc')
if @username
pending_scope = pending_scope.where(users: { cn: @username })
completed_scope = completed_scope.where(users: { cn: @username })
end
@pending_donations = pending_scope
@pagy, @donations = pagy(completed_scope)
@stats = {
overall_sats: @donations.sum("amount_sats"),
donor_count: Donation.completed.count(:user_id)
overall_sats: completed_scope.sum("amount_sats"),
donor_count: completed_scope.distinct.count(:user_id)
}
end
@@ -0,0 +1,45 @@
class Admin::EditableContentsController < Admin::BaseController
before_action :set_content, only: [:show, :edit, :update]
before_action :set_current_section, only: [:index, :show, :edit]
def index
@path = params[:path].presence
scope = EditableContent.order(path: :asc)
scope = scope.where(path: @path) if @path
@pagy, @contents = pagy(scope)
end
def show
end
def edit
end
def update
return_to = params[:editable_content][:redirect_to].presence
if @editable_content.update(content_params)
if return_to
redirect_to return_to
else
render status: :ok
end
else
render :edit, status: :unprocessable_entity
end
end
private
def set_content
@editable_content = EditableContent.find(params[:id])
end
def content_params
params.require(:editable_content).permit(:path, :key, :lang, :content, :rich_text)
end
def set_current_section
@current_section = :content
end
end
@@ -1,12 +1,28 @@
class Admin::InvitationsController < Admin::BaseController
before_action :set_current_section
def index
@current_section = :invitations
@pagy, @invitations_used = pagy(Invitation.used.order('used_at desc'))
@username = params[:username].presence
accepted_scope = Invitation.used.order('used_at desc')
unused_scope = Invitation.unused
if @username
accepted_scope = accepted_scope.joins(:user).where(users: { cn: @username })
unused_scope = unused_scope.joins(:user).where(users: { cn: @username })
end
@pagy, @invitations_used = pagy(accepted_scope)
@stats = {
available: Invitation.unused.count,
accepted: @invitations_used.length,
users_with_referrals: Invitation.used.distinct.count(:user_id)
available: unused_scope.count,
accepted: accepted_scope.count,
users_with_referrals: accepted_scope.distinct.count(:user_id)
}
end
private
def set_current_section
@current_section = :invitations
end
end
@@ -0,0 +1,23 @@
class Admin::Settings::MembershipController < Admin::SettingsController
def show
end
def update
update_settings
redirect_to admin_settings_membership_path, flash: {
success: "Settings saved"
}
end
private
def setting_params
params.require(:setting).permit([
:member_status_contributor,
:member_status_sustainer,
:user_index_show_contributors,
:user_index_show_sustainers
])
end
end
+21 -5
View File
@@ -4,18 +4,34 @@ class Admin::UsersController < Admin::BaseController
# GET /admin/users
def index
ldap = LdapService.new
@ou = Setting.primary_domain
@pagy, @users = pagy(User.where(ou: @ou).order(cn: :asc))
ldap = LdapService.new
ou = Setting.primary_domain
@show_contributors = Setting.user_index_show_contributors
@show_sustainers = Setting.user_index_show_sustainers
@contributors = ldap.search_users(:memberStatus, :contributor, :cn) if @show_contributors
@sustainers = ldap.search_users(:memberStatus, :sustainer, :cn) if @show_sustainers
@admins = ldap.search_users(:admin, true, :cn)
@username = params[:username].presence
users_scope = User.where(ou: ou).order(cn: :asc)
users_scope = users_scope.where("cn LIKE ?", "%#{User.sanitize_sql_like(@username.downcase)}%") if @username
@pagy, @users = pagy(users_scope)
@stats = {
users_confirmed: User.where(ou: @ou).confirmed.count,
users_pending: User.where(ou: @ou).pending.count
users_confirmed: User.where(ou: ou).confirmed.count,
users_pending: User.where(ou: ou).pending.count
}
@stats[:users_contributing] = @contributors.size if @show_contributors
@stats[:users_paying] = @sustainers.size if @show_sustainers
end
# GET /admin/users/:username
def show
@invitees = @user.invitees
@recent_invitees = @user.invitees.order(created_at: :desc).limit(5)
@more_invitees = (@invitees - @recent_invitees).count
if Setting.lndhub_admin_enabled?
@lndhub_user = @user.lndhub_user
end
+1 -1
View File
@@ -39,7 +39,7 @@ class ApplicationController < ActionController::Base
end
def after_sign_in_path_for(user)
session[:user_return_to] || root_path
session.delete(:user_return_to) || root_path
end
def lndhub_authenticate(options={})
@@ -11,7 +11,7 @@ class Contributions::DonationsController < ApplicationController
def index
@current_section = :contributions
@donations_completed = current_user.donations.completed.order('paid_at desc')
@donations_pending = current_user.donations.processing.order('created_at desc')
@donations_processing = current_user.donations.processing.order('created_at desc')
if Setting.lndhub_enabled?
begin
@@ -81,14 +81,11 @@ class Contributions::DonationsController < ApplicationController
case invoice["status"]
when "Settled"
@donation.paid_at = DateTime.now
@donation.payment_status = "settled"
@donation.save!
@donation.complete!
flash_message = { success: "Thank you!" }
when "Processing"
unless @donation.processing?
@donation.payment_status = "processing"
@donation.save!
@donation.start_processing!
flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." }
BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation)
end
@@ -0,0 +1,16 @@
class Contributions::OtherController < ApplicationController
before_action :authenticate_user!
before_action :set_content_editing
# GET /contributions/other
def index
@current_section = :contributions
end
private
def set_content_editing
return unless params[:edit] && current_user.is_admin?
@edit_content = true
end
end
@@ -1,8 +0,0 @@
class Contributions::ProjectsController < ApplicationController
before_action :authenticate_user!
# GET /contributions
def index
@current_section = :contributions
end
end
@@ -55,7 +55,12 @@ class Devise::PasswordsController < DeviseController
protected
def after_resetting_password_path_for(resource)
Devise.sign_in_after_reset_password ? after_sign_in_path_for(resource) : new_session_path(resource_name)
session.delete(:user_return_to)
if Devise.sign_in_after_reset_password
root_path
else
new_session_path(resource_name)
end
end
# The path used after sending reset password instructions
+9
View File
@@ -0,0 +1,9 @@
class PagesController < ApplicationController
def privacy
@current_section = :privacy
end
def tos
@current_section = :tos
end
end
+5 -4
View File
@@ -1,4 +1,5 @@
class Rs::OauthController < ApplicationController
prepend_before_action :assert_redirect_uri, only: [:new, :create]
before_action :require_signed_in_with_username, only: :new
before_action :authenticate_user!, only: :create
@@ -16,8 +17,6 @@ class Rs::OauthController < ApplicationController
["In 1 month", 1.month.from_now],
["In 1 day", 1.day.from_now]]
http_status :bad_request and return unless @redirect_uri.present?
unless current_user == @user
sign_out :user
@@ -64,8 +63,6 @@ class Rs::OauthController < ApplicationController
state = params[:state].presence
expire_at = params[:expire_at].presence
http_status :bad_request and return unless redirect_uri.present?
if permissions.empty?
redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state),
allow_other_host: true) and return
@@ -97,6 +94,10 @@ class Rs::OauthController < ApplicationController
private
def assert_redirect_uri
http_status :bad_request unless params[:redirect_uri].present?
end
def require_signed_in_with_username
unless user_signed_in?
session[:user_return_to] = request.url
@@ -23,7 +23,11 @@ class Services::RsAuthsController < Services::BaseController
end
def launch_app
launch_url = "#{@auth.launch_url}#remotestorage=#{current_user.address}&access_token=#{@auth.token}"
user_address = Rails.env.development? ?
"#{current_user.cn}@localhost:3000" :
current_user.address
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
redirect_to launch_url, allow_other_host: true
end
+6 -16
View File
@@ -35,7 +35,7 @@ class SettingsController < ApplicationController
if @user.avatar_new.present?
if store_user_avatar
LdapManager::UpdateAvatar.call(user: @user)
UserManager::UpdateAvatar.call(user: @user)
else
@validation_errors = @user.errors
render :show, status: :unprocessable_entity and return
@@ -192,7 +192,11 @@ class SettingsController < ApplicationController
def store_user_avatar
io = @user.avatar_new.tempfile
img_data = process_avatar(io)
img_data = UserManager::ProcessAvatar.call(io: io)
if img_data.blank?
@user.errors.add(:avatar, "failed to process file")
false
end
tempfile = Tempfile.create
tempfile.binmode
tempfile.write(img_data)
@@ -211,18 +215,4 @@ class SettingsController < ApplicationController
@user.save
end
end
def process_avatar(io)
processed = ImageProcessing::Vips
.source(io)
.resize_to_fill(400, 400)
.saver(strip: true)
.call
io.rewind
processed.read
rescue Vips::Error => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error { "Image processing failed for avatar: #{e.message}" }
nil
end
end
@@ -1,8 +1,16 @@
class WebKeyDirectoryController < WellKnownController
before_action :allow_cross_origin_requests
# /.well-known/openpgpkey/hu/:hashed_username(.txt)
# /.well-known/openpgpkey/hu/:hashed_username(.txt)?l=username
def show
if params[:l].blank?
# TODO store hashed username in db if existing implementations trigger
# this a lot
msg = "WKD request with \"l\" param omitted for hu: #{params[:hashed_username]}"
Sentry.capture_message(msg) if Setting.sentry_enabled?
http_status :bad_request and return
end
@user = User.find_by(cn: params[:l].downcase)
if @user.nil? ||
+6
View File
@@ -15,6 +15,12 @@ module ApplicationHelper
tag.span text, class: "inline-flex items-center rounded-full bg-#{color}-100 px-2.5 py-0.5 text-xs font-medium text-#{color}-800"
end
def markdown_to_html(string)
raw Kramdown::Document.new(string, {
input: "GFM"
}).to_html
end
def image_url_for(attachment)
return s3_image_url(attachment) if Setting.s3_enabled?
+1 -3
View File
@@ -10,9 +10,7 @@ class BtcpayCheckDonationJob < ApplicationJob
case invoice["status"]
when "Settled"
donation.paid_at = DateTime.now
donation.payment_status = "settled"
donation.save!
donation.complete!
NotificationMailer.with(user: donation.user)
.bitcoin_donation_confirmed
+1 -16
View File
@@ -2,21 +2,6 @@ class XmppExchangeContactsJob < ApplicationJob
queue_as :default
def perform(inviter, invitee)
return unless inviter.service_enabled?(:ejabberd) &&
invitee.service_enabled?(:ejabberd) &&
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
ejabberd = EjabberdApiClient.new
ejabberd.add_rosteritem({
"localuser": invitee.cn, "localhost": invitee.ou,
"user": inviter.cn, "host": inviter.ou,
"nick": inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
ejabberd.add_rosteritem({
"localuser": inviter.cn, "localhost": inviter.ou,
"user": invitee.cn, "host": invitee.ou,
"nick": invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
EjabberdManager::ExchangeContacts.call(inviter:, invitee:)
end
end
+1 -2
View File
@@ -2,7 +2,6 @@ class XmppSendMessageJob < ApplicationJob
queue_as :default
def perform(payload)
ejabberd = EjabberdApiClient.new
ejabberd.send_message payload
EjabberdManager::SendMessage.call(payload:)
end
end
+1 -91
View File
@@ -1,97 +1,7 @@
require 'digest'
require "image_processing/vips"
class XmppSetAvatarJob < ApplicationJob
queue_as :default
def perform(user:, overwrite: false)
@user = user
unless overwrite
current_avatar = get_current_avatar
Rails.logger.debug { "User #{user.cn} already has an avatar set. Nothing to do." }
return if current_avatar.present?
end
Rails.logger.debug { "Setting XMPP avatar for user #{user.cn}" }
stanzas = build_xep0084_stanzas
stanzas.each do |stanza|
res = ejabberd.send_stanza {
from: user.address, to: user.address, stanza: stanza
}
raise res.inspect if res.status != 200
end
EjabberdManager::SetAvatar.call(user:, overwrite:)
end
private
def ejabberd
@ejabberd ||= EjabberdApiClient.new
end
def get_current_avatar
res = ejabberd.get_vcard2 @user, "PHOTO", "BINVAL"
if res.status == 200
# VCARD PHOTO/BINVAL prop exists
res.body
elsif res.status == 400
# VCARD or PHOTO/BINVAL prop does not exist
nil
else
# Unexpected error, let job fail
raise res.inspect
end
end
def process_avatar
@user.avatar.blob.open do |file|
processed = ImageProcessing::Vips
.source(file)
.resize_to_fill(256, 256)
.convert("png")
.call
processed.read
end
end
def build_xep0084_stanzas
img_data = process_avatar
sha1_hash = Digest::SHA1.hexdigest(img_data)
base64_data = Base64.strict_encode64(img_data)
[
"""
<iq type='set' from='#{@user.address}' id='avatar-data-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:data'>
<item id='#{sha1_hash}'>
<data xmlns='urn:xmpp:avatar:data'>#{base64_data}</data>
</item>
</publish>
</pubsub>
</iq>
""".strip,
"""
<iq type='set' from='#{@user.address}' id='avatar-metadata-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:metadata'>
<item id='#{sha1_hash}'>
<metadata xmlns='urn:xmpp:avatar:metadata'>
<info bytes='#{img_data.size}'
id='#{sha1_hash}'
height='256'
type='image/png'
width='256'/>
</metadata>
</item>
</publish>
</pubsub>
</iq>
""".strip,
]
end
end
+1 -20
View File
@@ -2,25 +2,6 @@ class XmppSetDefaultBookmarksJob < ApplicationJob
queue_as :default
def perform(user)
return unless Setting.xmpp_default_rooms.any?
@user = user
ejabberd = EjabberdApiClient.new
ejabberd.private_set user, storage_content
end
def storage_content
bookmarks = ""
Setting.xmpp_default_rooms.each do |r|
bookmarks << conference_element(
jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
autojoin: Setting.xmpp_autojoin_default_rooms
)
end
"<storage xmlns='storage:bookmarks'>#{bookmarks}</storage>"
end
def conference_element(jid:, name:, autojoin: false, nick:)
"<conference jid='#{jid}' name='#{name}' autojoin='#{autojoin.to_s}'><nick>#{nick}</nick></conference>"
EjabberdManager::SetDefaultBookmarks.call(user:)
end
end
@@ -11,6 +11,9 @@ module Settings
field :mastodon_address_domain, type: :string,
default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain
field :mastodon_auth_token, type: :string,
default: ENV["MASTODON_AUTH_TOKEN"].presence
end
end
end
@@ -0,0 +1,18 @@
module Settings
module MembershipSettings
extend ActiveSupport::Concern
included do
field :member_status_contributor, type: :string,
default: "Contributor"
field :member_status_sustainer, type: :string,
default: "Sustainer"
# Admin panel
field :user_index_show_contributors, type: :boolean,
default: false
field :user_index_show_sustainers, type: :boolean,
default: false
end
end
end
+38 -5
View File
@@ -1,22 +1,42 @@
class Donation < ApplicationRecord
# Relations
include AASM
belongs_to :user
# Validations
validates_presence_of :user
validates_presence_of :donation_method,
inclusion: { in: %w[ custom btcpay lndhub ] }
validates_presence_of :payment_status, allow_nil: true,
inclusion: { in: %w[ processing settled ] }
inclusion: { in: %w[ pending processing settled ] }
validates_presence_of :paid_at, allow_nil: true
validates_presence_of :amount_sats, allow_nil: true
validates_presence_of :fiat_amount, allow_nil: true
validates_presence_of :fiat_currency, allow_nil: true,
inclusion: { in: %w[ EUR USD ] }
#Scopes
scope :pending, -> { where(payment_status: "pending") }
scope :processing, -> { where(payment_status: "processing") }
scope :completed, -> { where(payment_status: "settled") }
scope :completed, -> { where(payment_status: "settled") }
scope :incomplete, -> { where.not(payment_status: "settled") }
aasm column: :payment_status do
state :pending, initial: true
state :processing
state :settled
event :start_processing do
transitions from: :pending, to: :processing
end
event :complete do
transitions from: :processing, to: :settled, after: [:set_paid_at, :set_sustainer_status]
transitions from: :pending, to: :settled, after: [:set_paid_at, :set_sustainer_status]
end
end
def pending?
payment_status == "pending"
end
def processing?
payment_status == "processing"
@@ -25,4 +45,17 @@ class Donation < ApplicationRecord
def completed?
payment_status == "settled"
end
private
def set_paid_at
update paid_at: DateTime.now if paid_at.nil?
end
def set_sustainer_status
user.add_member_status :sustainer
rescue => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error("Failed to set memberStatus: #{e.message}")
end
end
+12
View File
@@ -0,0 +1,12 @@
class EditableContent < ApplicationRecord
validates :key, presence: true,
uniqueness: { scope: :context }
def has_content?
content.present?
end
def is_empty?
content.blank?
end
end
+1
View File
@@ -16,6 +16,7 @@ class Setting < RailsSettings::Base
include Settings::LightningNetworkSettings
include Settings::MastodonSettings
include Settings::MediaWikiSettings
include Settings::MembershipSettings
include Settings::NostrSettings
include Settings::OpenCollectiveSettings
include Settings::RemoteStorageSettings
+47 -15
View File
@@ -131,11 +131,11 @@ class User < ApplicationRecord
end
def is_admin?
admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin)
!!admin.first
else
false
end
@admin ||= if admin = Devise::LDAP::Adapter.get_ldap_param(self.cn, :admin)
!!admin.first
else
false
end
end
def address
@@ -163,7 +163,21 @@ class User < ApplicationRecord
def ldap_entry(reload: false)
return @ldap_entry if defined?(@ldap_entry) && !reload
@ldap_entry = ldap.fetch_users(uid: self.cn, ou: self.ou).first
@ldap_entry = ldap.fetch_users(cn: self.cn).first
end
def add_to_ldap_array(attr_key, ldap_attr, value)
current_entries = ldap_entry[attr_key.to_sym] || []
new_entries = Array(value).map(&:to_s)
entries = (current_entries + new_entries).uniq.sort
ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
end
def remove_from_ldap_array(attr_key, ldap_attr, value)
current_entries = ldap_entry[attr_key.to_sym] || []
entries_to_remove = Array(value).map(&:to_s)
entries = (current_entries - entries_to_remove).uniq.sort
ldap.replace_attribute(dn, ldap_attr.to_sym, entries)
end
def display_name
@@ -220,21 +234,39 @@ class User < ApplicationRecord
end
def enable_service(service)
current_services = services_enabled
new_services = Array(service).map(&:to_s)
services = (current_services + new_services).uniq.sort
ldap.replace_attribute(dn, :serviceEnabled, services)
add_to_ldap_array :services_enabled, :serviceEnabled, service
ldap_entry(reload: true)[:services_enabled]
end
def disable_service(service)
current_services = services_enabled
disabled_services = Array(service).map(&:to_s)
services = (current_services - disabled_services).uniq.sort
ldap.replace_attribute(dn, :serviceEnabled, services)
remove_from_ldap_array :services_enabled, :serviceEnabled, service
ldap_entry(reload: true)[:services_enabled]
end
def disable_all_services
ldap.delete_attribute(dn,:service)
ldap.delete_attribute(dn, :serviceEnabled)
end
def member_status
ldap_entry[:member_status] || []
end
def add_member_status(status)
add_to_ldap_array :member_status, :memberStatus, status
ldap_entry(reload: true)[:member_status]
end
def remove_member_status(status)
remove_from_ldap_array :member_status, :memberStatus, status
ldap_entry(reload: true)[:member_status]
end
def is_contributing_member?
member_status.map(&:to_sym).include?(:contributor)
end
def is_paying_member?
member_status.map(&:to_sym).include?(:sustainer)
end
private
+14 -28
View File
@@ -1,36 +1,22 @@
#
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
#
class BtcpayManagerService < ApplicationService
class BtcpayManagerService < RestApiService
private
def base_url
@base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}"
end
def base_url
@base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}"
end
def auth_token
@auth_token ||= Setting.btcpay_auth_token
end
def auth_token
@auth_token ||= Setting.btcpay_auth_token
end
def headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{auth_token}"
}
end
def endpoint_url(path)
"#{base_url}/#{path.gsub(/^\//, '')}"
end
def get(path, params = {})
res = Faraday.get endpoint_url(path), params, headers
JSON.parse(res.body)
end
def post(path, payload)
res = Faraday.post endpoint_url(path), payload.to_json, headers
JSON.parse(res.body)
end
def headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{auth_token}"
}
end
end
@@ -0,0 +1,25 @@
module EjabberdManager
class ExchangeContacts < EjabberdManagerService
def initialize(inviter:, invitee:)
@inviter = inviter
@invitee = invitee
end
def call
return unless @inviter.service_enabled?(:ejabberd) &&
@invitee.service_enabled?(:ejabberd) &&
@inviter.preferences[:xmpp_exchange_contacts_with_invitees]
add_rosteritem({
"localuser": @invitee.cn, "localhost": @invitee.ou,
"user": @inviter.cn, "host": @inviter.ou,
"nick": @inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
add_rosteritem({
"localuser": @inviter.cn, "localhost": @inviter.ou,
"user": @invitee.cn, "host": @invitee.ou,
"nick": @invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both"
})
end
end
end
@@ -0,0 +1,25 @@
module EjabberdManager
class GetAvatar < EjabberdManagerService
def initialize(user:)
@user = user
end
def call
res = get_vcard2 @user, "PHOTO", "BINVAL"
if res.status == 200
# VCARD PHOTO/BINVAL prop exists
img_base64 = JSON.parse(res.body)["content"]
ct_res = get_vcard2 @user, "PHOTO", "TYPE"
content_type = JSON.parse(ct_res.body)["content"]
{ content_type:, img_base64: }
elsif res.status == 400
# VCARD or PHOTO/BINVAL prop does not exist
nil
else
# Unexpected error, let job fail
raise res.inspect
end
end
end
end
@@ -0,0 +1,11 @@
module EjabberdManager
class SendMessage < EjabberdManagerService
def initialize(payload:)
@payload = payload
end
def call
send_message @payload
end
end
end
@@ -0,0 +1,80 @@
require 'digest'
require "image_processing/vips"
module EjabberdManager
class SetAvatar < EjabberdManagerService
def initialize(user:, overwrite: false)
@user = user
@overwrite = overwrite
end
def call
unless @overwrite
current_avatar = EjabberdManager::GetAvatar.call(user: @user)
Rails.logger.info { "User #{@user.cn} already has an avatar set" }
return if current_avatar.present?
end
Rails.logger.debug { "Setting XMPP avatar for user #{@user.cn}" }
stanzas = build_xep0084_stanzas
stanzas.each do |stanza|
payload = { from: @user.address, to: @user.address, stanza: stanza }
res = send_stanza payload
raise res.inspect if res.status != 200
end
end
end
private
def process_avatar
@user.avatar.blob.open do |file|
processed = ImageProcessing::Vips
.source(file)
.resize_to_fill(256, 256)
.convert("png")
.call
processed.read
end
end
# See https://xmpp.org/extensions/xep-0084.html
def build_xep0084_stanzas
img_data = process_avatar
sha1_hash = Digest::SHA1.hexdigest(img_data)
base64_data = Base64.strict_encode64(img_data)
[
"""
<iq type='set' from='#{@user.address}' id='avatar-data-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:data'>
<item id='#{sha1_hash}'>
<data xmlns='urn:xmpp:avatar:data'>#{base64_data}</data>
</item>
</publish>
</pubsub>
</iq>
""".strip,
"""
<iq type='set' from='#{@user.address}' id='avatar-metadata-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:metadata'>
<item id='#{sha1_hash}'>
<metadata xmlns='urn:xmpp:avatar:metadata'>
<info bytes='#{img_data.size}'
id='#{sha1_hash}'
height='256'
type='image/png'
width='256'/>
</metadata>
</item>
</publish>
</pubsub>
</iq>
""".strip,
]
end
end
@@ -0,0 +1,31 @@
module EjabberdManager
class SetDefaultBookmarks < EjabberdManagerService
def initialize(user:)
@user = user
end
def call
return unless Setting.xmpp_default_rooms.any?
private_set @user, storage_content
end
private
def storage_content
bookmarks = ""
Setting.xmpp_default_rooms.each do |r|
bookmarks << conference_element(
jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn,
autojoin: Setting.xmpp_autojoin_default_rooms
)
end
"<storage xmlns='storage:bookmarks'>#{bookmarks}</storage>"
end
def conference_element(jid:, name:, autojoin: false, nick:)
"<conference jid='#{jid}' name='#{name}' autojoin='#{autojoin.to_s}'><nick>#{nick}</nick></conference>"
end
end
end
@@ -1,11 +1,16 @@
class EjabberdApiClient
def initialize
@base_url = Setting.ejabberd_api_url
class EjabberdManagerService < RestApiService
private
def base_url
@base_url ||= Setting.ejabberd_api_url
end
def post(endpoint, payload)
Faraday.post "#{@base_url}/#{endpoint}", payload.to_json,
"Content-Type" => "application/json"
def headers
{ "Content-Type" => "application/json" }
end
def parse_responses?
false
end
#
@@ -6,7 +6,11 @@ module LdapManager
end
def call
replace_attribute @dn, :displayName, @display_name
if @display_name.present?
replace_attribute @dn, :displayName, @display_name
else
delete_attribute @dn, :displayName
end
end
end
end
+19 -10
View File
@@ -50,19 +50,17 @@ class LdapService < ApplicationService
end
def fetch_users(args={})
if args[:ou]
treebase = "ou=#{args[:ou]},cn=users,#{ldap_suffix}"
else
treebase = ldap_config["base"]
end
attributes = %w[
dn cn uid mail displayName admin serviceEnabled
dn cn uid mail displayName admin serviceEnabled memberStatus
mailRoutingAddress mailpassword nostrKey pgpKey
]
filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
filter = Net::LDAP::Filter.eq('objectClass', 'person') &
Net::LDAP::Filter.eq("cn", args[:cn] || "*")
entries = client.search(base: treebase, filter: filter, attributes: attributes)
entries = client.search(
base: ldap_config["base"], filter: filter,
attributes: attributes
)
entries.sort_by! { |e| e.cn[0] }
entries = entries.collect do |e|
{
@@ -71,6 +69,7 @@ class LdapService < ApplicationService
display_name: e.try(:displayName) ? e.displayName.first : nil,
admin: e.try(:admin) ? 'admin' : nil,
services_enabled: e.try(:serviceEnabled),
member_status: e.try(:memberStatus),
email_maildrop: e.try(:mailRoutingAddress),
email_password: e.try(:mailpassword),
nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil,
@@ -79,10 +78,20 @@ class LdapService < ApplicationService
end
end
def search_users(search_attr, value, return_attr)
filter = Net::LDAP::Filter.eq('objectClass', 'person') &
Net::LDAP::Filter.eq(search_attr.to_s, value.to_s) &
Net::LDAP::Filter.present('cn')
entries = client.search(
base: ldap_config["base"], filter: filter,
attributes: [return_attr]
)
entries.map { |entry| entry[return_attr].first }.compact
end
def fetch_organizations
attributes = %w{dn ou description}
filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit")
# filter = Net::LDAP::Filter.eq("objectClass", "*")
treebase = "cn=users,#{ldap_suffix}"
entries = client.search(base: treebase, filter: filter, attributes: attributes)
@@ -0,0 +1,12 @@
module MastodonManager
class FetchUser < MastodonManagerService
def initialize(mastodon_id:)
@mastodon_id = mastodon_id
end
def call
user = get "v1/admin/accounts/#{@mastodon_id}"
user.with_indifferent_access
end
end
end
@@ -0,0 +1,14 @@
module MastodonManager
class FindUser < MastodonManagerService
def initialize(username:)
@username = username
end
def call
users = get "v2/admin/accounts?username=#{@username}&origin=local"
users = users.map { |u| u.with_indifferent_access }
# Results may contain partial matches
users.find { |u| u.dig(:username).downcase == @username.downcase }
end
end
end
@@ -0,0 +1,64 @@
module MastodonManager
class SyncAccountProfiles < MastodonManagerService
def initialize(direction: "down", overwrite: false, user: nil)
@direction = direction
@overwrite = overwrite
@user = user
if @direction != "down"
raise NotImplementedError
end
end
def call
if @user
Rails.logger.debug { "Syncing account profile for user #{@user.cn} (direction: #{@direction}, overwrite: #{@overwrite})"}
users = User.where(cn: @user.cn)
else
Rails.logger.debug { "Syncing account profiles (direction: #{@direction}, overwrite: #{@overwrite})"}
users = User
end
users.find_each do |user|
if user.mastodon_id.blank?
mastodon_user = MastodonManager::FindUser.call username: user.cn
if mastodon_user
Rails.logger.debug { "Setting mastodon_id for user #{user.cn}" }
user.update! mastodon_id: mastodon_user.dig(:account, :id).to_i
else
Rails.logger.debug { "No Mastodon user found for username #{user.cn}" }
next
end
end
next if user.avatar.attached? && user.display_name.present?
unless mastodon_user
Rails.logger.debug { "Fetching Mastodon account with ID #{user.mastodon_id} for #{user.cn}" }
mastodon_user = MastodonManager::FetchUser.call mastodon_id: user.mastodon_id
end
if user.display_name.blank?
if mastodon_display_name = mastodon_user.dig(:account, :display_name)
Rails.logger.debug { "Setting display name for user #{user.cn} from Mastodon" }
LdapManager::UpdateDisplayName.call(
dn: user.dn, display_name: mastodon_display_name
)
end
end
if !user.avatar.attached?
if avatar_url = mastodon_user.dig(:account, :avatar_static)
Rails.logger.debug { "Importing Mastodon avatar for user #{user.cn}" }
UserManager::ImportRemoteAvatar.call(
user: user, avatar_url: avatar_url
)
end
end
rescue => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error e
end
end
end
end
+22
View File
@@ -0,0 +1,22 @@
#
# API Docs: https://docs.joinmastodon.org/methods/
#
class MastodonManagerService < RestApiService
private
def base_url
@base_url ||= "#{Setting.mastodon_public_url}/api"
end
def auth_token
@auth_token ||= Setting.mastodon_auth_token
end
def headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "Bearer #{auth_token}"
}
end
end
+29
View File
@@ -0,0 +1,29 @@
class RestApiService < ApplicationService
private
def base_url
raise NotImplementedError
end
def headers
raise NotImplementedError
end
def endpoint_url(path)
"#{base_url}/#{path.gsub(/^\//, '')}"
end
def parse_responses?
true
end
def get(path, params = {})
res = Faraday.get endpoint_url(path), params, headers
parse_responses? ? JSON.parse(res.body) : res
end
def post(path, payload)
res = Faraday.post endpoint_url(path), payload.to_json, headers
parse_responses? ? JSON.parse(res.body) : res
end
end
@@ -0,0 +1,42 @@
module UserManager
class ImportRemoteAvatar < UserManagerService
def initialize(user:, avatar_url:)
@user = user
@avatar_url = avatar_url
end
def call
if import_remote_avatar
UserManager::UpdateAvatar.call(user: @user)
end
end
private
def import_remote_avatar
tempfile = Down.download(@avatar_url)
content_type = tempfile.content_type
unless %w[image/jpeg image/png].include?(content_type)
Rails.logger.warn { "Wrong content type of remote avatar for user #{user.cn}: '#{content_type}'" }
return false
end
img_data = UserManager::ProcessAvatar.call(io: tempfile)
tempfile = Tempfile.create
tempfile.binmode
tempfile.write(img_data)
tempfile.rewind
hash = Digest::SHA256.hexdigest(img_data)
ext = content_type == "image/png" ? "png" : "jpg"
filename = "#{hash}.#{ext}"
key = "users/#{@user.cn}/avatars/#{filename}"
@user.avatar.attach io: tempfile, key: key, filename: filename
rescue => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.warn "Importing remote avatar failed: \"#{e.message}\""
false
end
end
end
@@ -0,0 +1,21 @@
module UserManager
class ProcessAvatar < UserManagerService
def initialize(io:)
@io = io
end
def call
processed = ImageProcessing::Vips
.source(@io)
.resize_to_fill(400, 400)
.saver(strip: true)
.call
@io.rewind
processed.read
rescue Vips::Error => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.warn { "Image processing failed for avatar: #{e.message}" }
nil
end
end
end
@@ -0,0 +1,16 @@
module UserManager
class UpdateAvatar < UserManagerService
def initialize(user:)
@user = user
end
def call
LdapManager::UpdateAvatar.call(user: @user)
if Setting.ejabberd_enabled?
return if Rails.env.development?
XmppSetAvatarJob.perform_later(user: @user)
end
end
end
end
@@ -0,0 +1,11 @@
<%= form_with url: path, method: :get, local: true, class: "flex gap-1" do %>
<%= text_field_tag :username, @username, placeholder: 'Filter by username' %>
<%= button_tag type: 'submit', name: nil, title: "Filter", class: 'btn-md btn-icon btn-outline' do %>
<%= render partial: "icons/filter", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
<% end %>
<% if @username %>
<%= link_to path, title: "Remove filter", class: 'btn-md btn-icon btn-outline' do %>
<%= render partial: "icons/x", locals: { custom_class: "text-red-600 h-4 w-4 inline" } %>
<% end %>
<% end %>
<% end %>
@@ -38,8 +38,7 @@
<tr>
<td><%= web_app.name %></td>
<td><%= link_to web_app.url, web_app.url,
target: "_blank", rel: "nofollow noopener",
class: "ks-text-link" %></td>
target: "_blank", rel: "nofollow noopener" %></td>
<td class="hidden md:table-cell"><%= web_app.remote_storage_authorizations.count %></td>
<td class="hidden md:table-cell">
<span title="<%= web_app.created_at %>" class="cursor-help">
+34
View File
@@ -0,0 +1,34 @@
<table class="divided">
<thead>
<tr>
<th>User</th>
<th class="text-right">Sats</th>
<th class="text-right">Fiat Amount</th>
<th class="pl-2">Public name</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
<% donations.each do |donation| %>
<tr>
<td><%= link_to donation.user.cn, admin_user_path(donation.user.cn) %></td>
<td class="text-right"><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %></td>
<td class="text-right"><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %></td>
<td class="pl-2"><%= donation.public_name %></td>
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : donation.created_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
<td class="text-right">
<%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% if defined?(pagy) %>
<div class="mt-8">
<%== pagy_nav pagy %>
</div>
<% end %>
+21 -34
View File
@@ -5,7 +5,7 @@
<%= render QuickstatsContainerComponent.new do %>
<%= render QuickstatsItemComponent.new(
type: :number,
title: 'Overall',
title: 'Received',
value: @stats[:overall_sats],
unit: 'sats'
) %>
@@ -19,41 +19,28 @@
</section>
<section>
<% if @donations.any? %>
<h3>Recent Donations</h3>
<table class="divided mb-8">
<thead>
<tr>
<th>User</th>
<th class="text-right">Sats</th>
<th class="text-right">Fiat Amount</th>
<th class="pl-2">Public name</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
<% @donations.each do |donation| %>
<tr>
<td><%= link_to donation.user.cn, admin_user_path(donation.user.cn), class: 'ks-text-link' %></td>
<td class="text-right"><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %></td>
<td class="text-right"><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %></td>
<td class="pl-2"><%= donation.public_name %></td>
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td>
<td class="text-right">
<%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
<%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav @pagy %>
<%= render partial: "admin/username_search_form",
locals: { path: admin_donations_path } %>
</section>
<% if @pending_donations.present? %>
<section>
<h3>Pending</h3>
<%= render partial: "admin/donations/list", locals: {
donations: @pending_donations
} %>
</section>
<% end %>
<section>
<% if @donations.present? %>
<h3>Received</h3>
<%= render partial: "admin/donations/list", locals: {
donations: @donations, pagy: @pagy
} %>
<% else %>
<p>
No donations yet.
No donations received yet.
</p>
<% end %>
</section>
+10 -2
View File
@@ -6,7 +6,7 @@
<tbody>
<tr>
<th>User</th>
<td><%= link_to @donation.user.cn, admin_user_path(@donation.user.cn), class: 'ks-text-link' %></td>
<td><%= link_to @donation.user.cn, admin_user_path(@donation.user.cn) %></td>
</tr>
<tr>
<th>Donation Method</th>
@@ -25,7 +25,15 @@
<td><%= @donation.public_name %></td>
</tr>
<tr>
<th>Date</th>
<th>Payment status</th>
<td><%= @donation.payment_status %></td>
</tr>
<tr>
<th>Created at</th>
<td><%= @donation.created_at&.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
</tr>
<tr>
<th>Paid at</th>
<td><%= @donation.paid_at&.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
</tr>
</tbody>
+9 -3
View File
@@ -21,9 +21,15 @@
) %>
<% end %>
</section>
<section>
<%= render partial: "admin/username_search_form",
locals: { path: admin_invitations_path } %>
</section>
<% if @invitations_used.any? %>
<section>
<h3>Recently Accepted</h3>
<h3>Accepted</h3>
<table class="divided mb-8">
<thead>
<tr>
@@ -38,8 +44,8 @@
<tr>
<td class="overflow-ellipsis font-mono"><%= invitation.token %></td>
<td><%= invitation.used_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
<td><%= link_to invitation.user.cn, admin_user_path(invitation.user.cn), class: "ks-text-link" %></td>
<td><%= link_to invitation.invitee.cn, admin_user_path(invitation.invitee.cn), class: "ks-text-link" %></td>
<td><%= link_to invitation.user.cn, admin_user_path(invitation.user.cn) %></td>
<td><%= link_to invitation.invitee.cn, admin_user_path(invitation.invitee.cn) %></td>
</tr>
<% end %>
</tbody>
+1 -1
View File
@@ -36,7 +36,7 @@
</td>
<td>
<% if user = @users.find{ |u| u[2] == account.login } %>
<%= link_to user[0], admin_user_path(user[0]), class: "ks-text-link" %>
<%= link_to user[0], admin_user_path(user[0]) %>
<% end %>
</td>
<td><%= number_with_delimiter account.balance.to_i.to_s %></td>
@@ -0,0 +1,53 @@
<%= render HeaderComponent.new(title: "Settings") %>
<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %>
<%= form_for(Setting.new, url: admin_settings_membership_path, method: :put) do |f| %>
<section>
<h3>Membership</h3>
<% if @errors && @errors.any? %>
<%= render partial: "admin/settings/errors", locals: { errors: @errors } %>
<% end %>
<ul role="list">
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :member_status_contributor,
title: "Status name for contributing users",
description: "A contributing member of your organization/group"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :member_status_sustainer,
title: "Status name for paying users",
description: "A paying/donating member or customer"
) %>
</ul>
</section>
<section>
<h3>Admin panel</h3>
<ul role="list">
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :user_index_show_contributors,
enabled: Setting.user_index_show_contributors?,
title: "Show #{Setting.member_status_contributor.downcase} status in user list",
description: "Can slow down page rendering with large user base"
) %>
<%= render FormElements::FieldsetToggleComponent.new(
form: f,
attribute: :user_index_show_sustainers,
enabled: Setting.user_index_show_sustainers?,
title: "Show #{Setting.member_status_sustainer.downcase} status in user list",
description: "Can slow down page rendering with large user base"
) %>
</ul>
</section>
<section>
<p class="pt-6 border-t border-gray-200 text-right">
<%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
</p>
</section>
<% end %>
<% end %>
@@ -16,5 +16,10 @@
key: :mastodon_address_domain,
title: "User address domain"
) %>
<%= render FormElements::FieldsetResettableSettingComponent.new(
key: :mastodon_auth_token,
type: :password,
title: "API auth token"
) %>
<% end %>
</ul>
+26 -3
View File
@@ -13,9 +13,28 @@
title: 'Pending',
value: @stats[:users_pending],
) %>
<% if @show_contributors %>
<%= render QuickstatsItemComponent.new(
type: :number,
title: Setting.member_status_contributor.pluralize,
value: @stats[:users_contributing],
) %>
<% end %>
<% if @show_sustainers %>
<%= render QuickstatsItemComponent.new(
type: :number,
title: Setting.member_status_sustainer.pluralize,
value: @stats[:users_paying],
) %>
<% end %>
<% end %>
</section>
<section>
<%= render partial: "admin/username_search_form",
locals: { path: admin_users_path } %>
</section>
<section>
<table class="divided mb-8">
<thead>
@@ -28,9 +47,13 @@
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= link_to(user.cn, admin_user_path(user.cn), class: 'ks-text-link') %></td>
<td><%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %></td>
<td><%= user.is_admin? ? badge("admin", :red) : "" %></td>
<td><%= link_to(user.cn, admin_user_path(user.cn)) %></td>
<td>
<%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %>
<% if @show_contributors %><%= @contributors.include?(user.cn) ? badge("contributor", :green) : "" %><% end %>
<% if @show_sustainers %><%= @sustainers.include?(user.cn) ? badge("sustainer", :green) : "" %><% end %>
</td>
<td><%= @admins.include?(user.cn) ? badge("admin", :red) : "" %></td>
</tr>
<% end %>
</tbody>
+63 -8
View File
@@ -32,11 +32,35 @@
<th>Roles</th>
<td><%= @user.is_admin? ? badge("admin", :red) : "—" %></td>
</tr>
<tr>
<th>Status</th>
<td>
<% if @user.is_contributing_member? || @user.is_paying_member? %>
<%= @user.is_contributing_member? ? badge("contributor", :green) : "" %>
<%= @user.is_paying_member? ? badge("sustainer", :green) : "" %>
<% else %>
<% end %>
</td>
</tr>
<tr>
<th>Donations</th>
<td>
<% if @user.donations.any? %>
<%= link_to admin_donations_path(username: @user.cn) do %>
<%= @user.donations.completed.count %> for
<%= number_with_delimiter @user.donations.completed.sum("amount_sats") %> sats
<% end %>
<% else %>
<% end %>
</td>
</tr>
<tr>
<th>Invited by</th>
<td>
<% if @user.inviter %>
<%= link_to @user.inviter.cn, admin_user_path(@user.inviter.cn), class: 'ks-text-link' %>
<%= link_to @user.inviter.cn, admin_user_path(@user.inviter.cn) %>
<% else %>&mdash;<% end %>
</td>
</tr>
@@ -45,7 +69,7 @@
<td data-controller="modal" data-action="keydown.esc->modal#close">
<div class="flex justify-between">
<span>
<%= @user.invitations.count %>
<%= @user.invitations.unused.count %>
</span>
<span>
<button id="add-invitations" data-action="click->modal#open">
@@ -75,10 +99,13 @@
<tr>
<th class="align-top">Invited users</th>
<td class="align-top">
<% if @user.invitees.length > 0 %>
<% if @invitees.any? %>
<ul class="mb-0">
<% @user.invitees.order(cn: :asc).each do |invitee| %>
<li class="leading-none mb-2 last:mb-0"><%= link_to invitee.cn, admin_user_path(invitee.cn), class: 'ks-text-link' %></li>
<% @recent_invitees.each do |invitee| %>
<li class="leading-none mb-2 last:mb-0"><%= link_to invitee.cn, admin_user_path(invitee.cn) %></li>
<% end %>
<% if @more_invitees > 0 %>
<li>and <%= link_to "#{@more_invitees} more", admin_invitations_path(username: @user.cn) %></li>
<% end %>
</ul>
<% else %>&mdash;<% end %>
@@ -89,14 +116,42 @@
</section>
<section class="sm:flex-1 sm:pt-0">
<h3>LDAP</h3>
<h3>Avatar</h3>
<% if @user.avatar.attached? %>
<table class="divided">
<tbody>
<tr>
<th class="align-top">Image</th>
<td class="align-top">
<%= image_tag image_url_for(@user.avatar), class: "h-20 w-20 rounded-lg" %>
</td>
</tr>
<tr>
<th>Content type</th>
<td>
<%= @user.avatar.content_type %>
</td>
</tr>
<tr>
<th>Size</th>
<td>
<%= number_to_human_size(@user.avatar.blob.byte_size) %>
</td>
</tr>
</tbody>
</table>
<% else %>
<p class="text-gray-500">No avatar uploaded</p>
<% end %>
<h3 class="mt-12">LDAP</h3>
<table class="divided">
<tbody>
<tr>
<th>Avatar</th>
<td>
<% if @ldap_avatar.present? %>
JPEG size: <%= @ldap_avatar.size %>
JPEG size: <%= number_to_human_size(@ldap_avatar.size) %>
<% else %>
&mdash;
<% end %>
@@ -113,7 +168,7 @@
<span class="font-mono" title="<%= @user.pgp_fpr %>">
<% if @user.pgp_pubkey_contains_user_address? %>
<%= link_to wkd_key_url(hashed_username: @user.wkd_hash, l: @user.cn, format: :txt),
class: "ks-text-link", target: "_blank" do %>
target: "_blank" do %>
<%= "#{@user.pgp_fpr[0, 8]}#{@user.pgp_fpr[-8..-1]}" %>
<% end %>
<% else %>
@@ -3,7 +3,7 @@
<%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %>
<section>
<p class="mb-12">
Your financial contributions to the development and upkeep of Kosmos
Your financial contributions to the development and upkeep of our
software and services.
</p>
</section>
@@ -22,17 +22,17 @@
</div>
</section>
<% if @donations_pending.any? %>
<% if @donations_processing.any? %>
<section class="donation-list">
<h2>Pending</h2>
<%= render partial: "contributions/donations/list",
locals: { donations: @donations_pending } %>
locals: { donations: @donations_processing } %>
</section>
<% end %>
<% if @donations_completed.any? %>
<section class="donation-list">
<h2>Past contributions</h2>
<h2>Contributions</h2>
<%= render partial: "contributions/donations/list",
locals: { donations: @donations_completed } %>
</section>
@@ -0,0 +1,20 @@
<%= render HeaderComponent.new(title: "Contributions") %>
<%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %>
<section>
<%= render EditableContentComponent.new(
context: "contributions/other", key: "body", rich_text: true,
default: "No content yet") %>
<% if current_user.is_admin? %>
<div class="mt-8 pt-6 border-t border-gray-200 text-right">
<%= render EditContentButtonComponent.new(
context: "contributions/other", key: "title",
redirect_to: request.path) do %>Edit title<% end %>
<%= render EditContentButtonComponent.new(
context: "contributions/other", key: "body", rich_text: true,
redirect_to: request.path) do %>Edit content<% end %>
</div>
<% end %>
</section>
<% end %>
@@ -1,49 +0,0 @@
<%= render HeaderComponent.new(title: "Contributions") %>
<%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %>
<section>
<p class="mb-8">
Project contributions are how we develop and run all Kosmos software and
services. Everything we create and provide is free and open-source
software, even the page you're looking at right now!
</p>
<h3>Start contributing</h3>
<p>
Check out our
<a href="https://kosmos.org/projects/" target="_blank" class="ks-text-link">projects page</a>
for some (but not all) potential places that can use your help.
</p>
<p>
There's something to do for everyone, especially non-programmers! For
example, we need more help with graphics, UI/UX design, and
content/copywriting. Also, testing any of our software and reporting
issues you encounter along the way is very valuable.
</p>
<p>
A good way to get started is to join one of our
<a href="https://wiki.kosmos.org/Main_Page#Chat" target="_blank" class="ks-text-link">chat rooms</a>
and introduce yourself. Alternatively, you can also ping us on any other
medium, or even just grab an open issue on our
<a href="https://gitea.kosmos.org/kosmos/" target="_blank" class="ks-text-link">Gitea</a>
or on
<a href="https://github.com/67P/" target="_blank" class="ks-text-link">GitHub</a>
and dive right in.
</p>
<p class="mb-8">
Last but not least, if you want to help by proposing new features or
services, or by giving feedback on existing ones, head over to the
<a href="https://community.kosmos.org/" target="_blank" class="ks-text-link">community forums</a>,
where you can do just that.
</p>
<h3>Open Source Grants</h3>
<p>
Money coming in from financial contributions is first used to pay for our
bills. Additional funds are being paid out directly to our contributors,
including you, according to their rough share of contributions.
</p>
<p>
We have run two 6-month trials so far, with the next trial period
starting sometime soon. Watch your email for notifications about it!
</p>
</section>
<% end %>
+12 -10
View File
@@ -8,7 +8,7 @@
bg-[length:86%] bg-[center_top_-40px] bg-no-repeat
bg-[url(/img/logos/icon_xmpp.svg)]">
<%= link_to services_chat_path,
class: "block h-full px-6 py-6 rounded-md" do %>
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Chat</h3>
<p class="text-gray-600">
Federated chat rooms and instant messaging
@@ -20,7 +20,8 @@
<div class="border border-gray-300 rounded-md hover:border-gray-400
bg-[length:88%] bg-[center_top_-40px] bg-no-repeat
bg-[url(/img/logos/icon_mastodon.svg)]">
<%= link_to services_mastodon_path, class: "block h-full px-6 py-6 rounded-md" do %>
<%= link_to services_mastodon_path,
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Mastodon</h3>
<p class="text-gray-600">
Your account on the Open Social Web
@@ -33,7 +34,8 @@
<div class="border border-gray-300 rounded-md hover:border-gray-400
bg-[length:90%] bg-[center_top_-160px] bg-no-repeat
bg-[url(/img/logos/icon_mail.svg)]">
<%= link_to services_email_path, class: "block h-full px-6 py-6 rounded-md" do %>
<%= link_to services_email_path,
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">E-Mail</h3>
<p class="text-gray-600">
A no-bullshit email account
@@ -47,7 +49,7 @@
bg-[length:80%] bg-[center_top_-156px] bg-no-repeat
bg-[url(/img/logos/icon_remotestorage.svg)]">
<%= link_to services_storage_path,
class: "block h-full px-6 py-6 rounded-md" do %>
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Storage</h3>
<p class="text-gray-600">
Sync your data between apps and devices
@@ -60,7 +62,7 @@
bg-cover bg-center sm:bg-[center_top_-140px] bg-no-repeat
bg-[url(/img/logos/icon_lightning.svg)]">
<%= link_to services_lightning_index_path,
class: "block h-full px-6 py-6 rounded-md" do %>
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Lightning Network</h3>
<p class="text-gray-600">
Send and receive sats over the Bitcoin Lightning Network
@@ -73,7 +75,7 @@
bg-[length:80%] bg-center bg-no-repeat
bg-[url(/img/logos/icon_discourse.svg)]">
<%= link_to "#{Setting.discourse_public_url}/session/sso?return_path=/",
class: "block h-full px-6 py-6 rounded-md" do %>
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Discourse</h3>
<p class="text-gray-600">
Community forums and support/help site
@@ -86,7 +88,7 @@
bg-[length:92%] bg-center bg-no-repeat
bg-[url(/img/logos/icon_gitea.png)]">
<%= link_to Setting.gitea_public_url,
class: "block h-full px-6 py-6 rounded-md" do %>
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Gitea</h3>
<p class="text-gray-600">
Code hosting and collaboration for software projects
@@ -99,7 +101,7 @@
bg-[length:86%] bg-[center_top_-60px] bg-no-repeat
bg-[url(/img/logos/icon_droneci.svg)]">
<%= link_to Setting.droneci_public_url,
class: "block h-full px-6 py-6 rounded-md" do %>
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Drone CI</h3>
<p class="text-gray-600">
Continuous integration for software projects on Gitea
@@ -112,10 +114,10 @@
bg-cover bg-[center_top_-20px] bg-no-repeat
bg-[url(/img/logos/icon_mediawiki.svg)]">
<%= link_to Setting.mediawiki_public_url,
class: "block h-full px-6 py-6 rounded-md" do %>
class: "block h-full px-6 py-6 rounded-md btn-text-dark" do %>
<h3 class="mb-3.5">Wiki</h3>
<p class="text-gray-600">
Kosmos documentation and knowledge base
Documentation and knowledge base
</p>
<% end %>
</div>
+1 -1
View File
@@ -47,7 +47,7 @@
data: { action: "click->settings--toggle#toggleSwitch" } %>
<p class="grow text-sm text-right">
<%= link_to "Forgot your password?", new_password_path(resource_name),
class: "text-gray-500 underline" %><br />
class: "text-gray-500 visited:text-gray-500 underline" %><br />
</p>
<% end %>
+4 -4
View File
@@ -2,28 +2,28 @@
<%- if controller_name != 'sessions' %>
<p class="mb-2">
<%= link_to "Log in", new_session_path(resource_name),
class: "text-gray-500 underline" %>
class: "text-gray-500 visited:text-gray-500 underline" %>
</p>
<% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<p class="mb-2">
<%= link_to "Forgot your password?", new_password_path(resource_name),
class: "text-gray-500 underline" %>
class: "text-gray-500 visited:text-gray-500 underline" %>
</p>
<% end %>
<%- if devise_mapping.confirmable? && !controller_name.match(/^(confirmations|sessions)$/) %>
<p class="mb-2">
<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name),
class: "text-gray-500 underline" %>
class: "text-gray-500 visited:text-gray-500 underline" %>
</p>
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<p class="mb-2">
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name),
class: "text-gray-500 underline" %>
class: "text-gray-500 visited:text-gray-500 underline" %>
</p>
<% end %>
</div>
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-activity"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-activity <%= local_assigns[:custom_class] %>"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 318 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-airplay"><path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"></path><polygon points="12 15 17 21 7 21 12 15"></polygon></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-airplay <%= local_assigns[:custom_class] %>"><path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"></path><polygon points="12 15 17 21 7 21 12 15"></polygon></svg>

Before

Width:  |  Height:  |  Size: 362 B

After

Width:  |  Height:  |  Size: 398 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle <%= local_assigns[:custom_class] %>"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 392 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-octagon"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-octagon <%= local_assigns[:custom_class] %>"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 452 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle <%= custom_class %>"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle <%= local_assigns[:custom_class] %>"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>

Before

Width:  |  Height:  |  Size: 445 B

After

Width:  |  Height:  |  Size: 461 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-center"><line x1="18" y1="10" x2="6" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="18" y1="18" x2="6" y2="18"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-center <%= local_assigns[:custom_class] %>"><line x1="18" y1="10" x2="6" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="18" y1="18" x2="6" y2="18"></line></svg>

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 434 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-justify"><line x1="21" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="3" y2="18"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-justify <%= local_assigns[:custom_class] %>"><line x1="21" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="3" y2="18"></line></svg>

Before

Width:  |  Height:  |  Size: 399 B

After

Width:  |  Height:  |  Size: 435 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-left"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-left <%= local_assigns[:custom_class] %>"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg>

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 432 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-right"><line x1="21" y1="10" x2="7" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="7" y2="18"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-right <%= local_assigns[:custom_class] %>"><line x1="21" y1="10" x2="7" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="7" y2="18"></line></svg>

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 433 B

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