Compare commits

...

219 Commits

Author SHA1 Message Date
6e1ee839ba
Merge pull request 'modernize-oauth' (!76) from modernize-oauth into master
All checks were successful
rvr-nextgen/pipeline/tag This commit looks good
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #76
2024-11-26 21:32:41 +01:00
cac57d7f71
decease session expiration time
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2024-11-26 21:11:27 +01:00
cde14ee779
modernize oauth token handling 2024-11-26 21:11:27 +01:00
dfcdd8dca7
update yarn.lock 2024-11-26 21:07:04 +01:00
a5286bf62f
add .well-known/openid-configuration to the root as well 2024-11-26 21:07:04 +01:00
ec4d3806ce
Merge pull request 'do not recreate docker runner group and user' (!75) from do-no-recreate-group-and-user into master
All checks were successful
rvr-nextgen/pipeline/tag This commit looks good
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #75
2024-03-11 00:15:07 +01:00
0c2334502d
do not recreate docker runner group and user
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2024-03-10 23:53:11 +01:00
6341072b0b
Merge pull request 'fix regex for tagging' (!74) from bugfix/fix-regex into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
rvr-nextgen/pipeline/tag This commit looks good
Reviewed-on: #74
2023-10-01 00:15:15 +02:00
1dbd813bef
fix regex for tagging
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-10-01 00:13:27 +02:00
14d83d24b4
Merge pull request 'push fixed version images' (!73) from feature/auto-release-fixed-tags into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #73
2023-10-01 00:07:52 +02:00
ba02b6d8cb
push fixed version images
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-10-01 00:06:38 +02:00
9b8fcaad9a
Merge pull request 'error reporting should always be E_ALL' (!72) from bugfix/fix-error-reporting into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #72
2023-09-30 23:37:24 +02:00
6bd6ede442
error reporting should always be E_ALL
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-09-30 23:18:42 +02:00
65dac4640a
Merge pull request 'define forwarded_scheme before use' (!71) from bugfix/pass-scheme-to-fastcgi into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
rvr-nextgen/pipeline/tag This commit looks good
Reviewed-on: #71
2023-09-28 15:00:21 +02:00
7bd12050f6
define forwarded_scheme before use
Some checks are pending
rvr-nextgen/pipeline/pr-master Build queued...
2023-09-28 14:59:55 +02:00
5041258de0
Merge pull request 'pass scheme to fastcgi' (!70) from bugfix/pass-scheme-to-fastcgi into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
rvr-nextgen/pipeline/tag This commit looks good
Reviewed-on: #70
2023-09-28 14:55:37 +02:00
47928d2d2b
pass scheme to fastcgi
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-09-28 14:43:30 +02:00
55cf2afde3
Merge pull request 'feature/update-to-ubuntu-2204' (!69) from feature/update-to-ubuntu-2204 into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #69
2023-09-28 14:42:43 +02:00
ed2b1c23ae
update soko-web
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-09-28 14:30:59 +02:00
6ef45b8b6a
generate composer.lock 2023-09-28 14:30:58 +02:00
b4c359e81d
update phpunit 2023-09-28 14:30:58 +02:00
495a2fe910
update ubuntu to 22.04 and php to 8.1 2023-09-28 14:30:58 +02:00
37ba0ec172
Merge pull request 'feature/create-working-docker-images' (!68) from feature/create-working-docker-images into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
rvr-nextgen/pipeline/tag This commit looks good
Reviewed-on: #68
2023-09-28 13:33:02 +02:00
9ffde6bccb
update readme
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-09-28 13:31:26 +02:00
0ce1c4f28a
update soko-web 2023-09-28 13:21:54 +02:00
0d1e4a3d1c
update docker-compose 2023-09-28 13:21:54 +02:00
cbf62d1c4a
add docker release stage to pipeline 2023-09-28 13:21:54 +02:00
5eeac18b4c
use the new dockerfile in pipeline 2023-09-28 13:21:54 +02:00
c4dce94f5e
delete deprecated dockerfiles 2023-09-28 13:21:54 +02:00
147b7690ac
add new dockerfile with dev and release stages 2023-09-28 13:21:54 +02:00
bdfa46b838
add entry point for dev docker 2023-09-28 13:21:54 +02:00
27fe883f59
add entry point for release docker 2023-09-28 13:21:54 +02:00
1718dfca9e
install base database in MigrateDatabaseCommand 2023-09-28 13:21:54 +02:00
6870d08c21
remove deprecated scripts 2023-09-28 13:21:54 +02:00
eba674bfbc
add cron for db:maintain 2023-09-28 13:21:54 +02:00
832170b1e1
add release generator script 2023-09-28 13:21:54 +02:00
8dfea0993b
add nodejs installer script 2023-09-28 13:21:54 +02:00
6146641eed
Merge pull request 'fix selectUpcomingAndRecent when used with other selects' (!67) from bugfix/upcoming-events-are-multiplicated into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #67
2023-08-06 22:09:33 +02:00
7cb406cc49
fix selectUpcomingAndRecent when used with other selects
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-08-06 22:06:42 +02:00
2d2cf9c6f0
Merge pull request 'make short new transaction url even shorter' (!66) from feature/change-short-new-transaction-url into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #66
2023-07-24 21:43:39 +02:00
ca069d0795
make short new transaction url even shorter
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-07-24 21:42:27 +02:00
17d9437a50
Merge pull request 'fix select to avoid getting all events as upcoming/recent' (!65) from bugfix/fix-select-for-upcoming-and-recent-events into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #65
2023-07-24 02:12:15 +02:00
aed4548c7b
fix select to avoid getting all events as upcoming/recent
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-07-24 02:11:27 +02:00
0d6d152b82
Merge pull request 'create endpoints to get current event and current event new transaction' (!64) from feature/create-endpoint-for-currect-event-redirection into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #64
2023-07-24 02:08:11 +02:00
7beec510a1
create endpoints to get current event and current event new transaction
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-07-24 02:00:59 +02:00
22b849a1c8
Merge pull request 'select current user as payer by default for new transaction' (!63) from feature/default-payer-is-current-user into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #63
2023-07-24 01:31:22 +02:00
492aa9fcef
select current user as payer by default for new transaction
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-07-24 01:29:53 +02:00
b6da70e015
Merge pull request 'fix transaction edit header' (!62) from bugfix/fix-transaction-edit-header into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #62
2023-07-12 00:40:06 +02:00
afead7fd61
Merge pull request 'show balance for events' (!61) from feature/show-balance-for-events into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #61
2023-07-12 00:38:17 +02:00
69acbf708b
fix transaction edit header
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-07-12 00:32:55 +02:00
08ebfae030
show balance for events
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-07-12 00:26:59 +02:00
a597839379
Merge pull request 'feature/RVRNEXT-45-show-recent-events-as-well' (!60) from feature/RVRNEXT-45-show-recent-events-as-well into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #60
2023-07-08 14:44:35 +02:00
e0fe6a8622
RVRNEXT-45 update soko-web to 0.13.1
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-07-08 14:42:09 +02:00
5ebce653dc
RVRNEXT-45 show recent events as well with upcoming events 2023-07-08 13:52:24 +02:00
61eb2404a2
Merge pull request 'feature/RVRNEXT-43-split-transactions' (!59) from feature/RVRNEXT-43-split-transactions into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #59
2023-06-17 14:59:09 +02:00
e199dfccc7
RVRNEXT-43 update soko-web
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-06-17 14:35:00 +02:00
1d0a2b09d5
RVRNEXT-43 adapt balance calculations to multiple payees 2023-06-17 13:47:05 +02:00
1886257b63
RVRNEXT-43 adapt views to multiple payees 2023-06-17 13:46:43 +02:00
eda1415e86
RVRNEXT-43 implement multiple payees in transaction controller 2023-06-17 13:46:13 +02:00
4e9c7f8c3c
RVRNEXT-43 extend community member repository with count 2023-06-17 13:17:29 +02:00
eebf4aff00
RVRNEXT-43 add multi relation for transaction 2023-06-17 13:16:53 +02:00
6dae33507f
RVRNEXT-43 add repository for transaction payees 2023-06-17 13:15:54 +02:00
96d5aa2219
RVRNEXT-43 add model for transaction payee 2023-06-17 13:14:43 +02:00
6c326b89e2
RVRNEXT-43 add migration for transaction payees 2023-06-17 13:14:33 +02:00
6c821d41b4
Merge pull request 'feature/RVRNEXT-39-delete-communities' (!58) from feature/RVRNEXT-39-delete-communities into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #58
2023-06-16 21:39:45 +02:00
ade02f4736
RVRNEXT-39 add endpoint for community delete
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-06-16 21:38:27 +02:00
bb5b510315
RVRNEXT-39 add delete button for communities 2023-06-16 21:38:27 +02:00
1267cb3d95
RVRNEXT-39 implement community delete 2023-06-16 21:38:26 +02:00
c7641b85e7
RVRNEXT-39 make main currency nullable 2023-06-16 21:28:42 +02:00
ee4b8e9d65
Merge pull request 'bugfix/RVRNEXT-31-allow-special-characters-for-local-assets' (!57) from bugfix/RVRNEXT-31-allow-special-characters-for-local-assets into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #57
2023-05-28 22:31:21 +02:00
58d4f982e9
RVRNEXT-31 remove hack for assets of tom-select
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-28 22:30:00 +02:00
ad06cfaa58
RVRNEXT-31 update soko-web 2023-05-28 22:30:00 +02:00
aa2e0e9b4a
Merge pull request 'feature/RVRNEXT-22-add-pagination-template' (!56) from feature/RVRNEXT-22-add-pagination-template into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #56
2023-05-28 20:46:22 +02:00
d1c926541a
RVRNEXT-22 use the new repository methods in controllers
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-28 20:34:07 +02:00
9929e00a3f
RVRNEXT-22 use Select::paginate in repositories 2023-05-28 20:34:07 +02:00
bf415a2a46
RVRNEXT-22 use pagination template 2023-05-28 20:34:00 +02:00
c0bfa5b334
RVRNEXT-22 add pagination template 2023-05-28 20:33:59 +02:00
e122248a97
Merge pull request 'fix sql statement for table transactions' (!55) from bugfix/fix-buggy-migration into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #55
2023-05-28 17:03:11 +02:00
d2bb1d4936
fix sql statement for table transactions
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-28 17:01:19 +02:00
b8a901b041
Merge pull request 'feature/RVRNEXT-11-events' (!54) from feature/RVRNEXT-11-events into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #54
2023-05-28 16:52:48 +02:00
ede5c24a5d
RVRNEXT-11 fix archive in pipeline
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-28 16:48:38 +02:00
0ae7040804
RVRNEXT-11 update soko-web to 0.12.1 2023-05-28 16:48:38 +02:00
f370ccba88
RVRNEXT-11 add upcoming events for user 2023-05-28 16:48:38 +02:00
a128168e7f
RVRNEXT-11 fix select in community members form 2023-05-28 16:48:38 +02:00
3e9c25f6fe
RVRNEXT-11 style improvements 2023-05-28 16:48:38 +02:00
352311a82e
RVRNEXT-11 add custom title for main layout 2023-05-28 16:48:38 +02:00
c97d80a724
RVRNEXT-11 add upcoming events for community 2023-05-28 16:48:38 +02:00
532dbfd8d6
RVRNEXT-11 add event for transactions 2023-05-28 16:48:37 +02:00
49f3a371e5
RVRNEXT-11 add views for events 2023-05-28 16:48:37 +02:00
2bcaf13ee3
RVRNEXT-11 add controller for events 2023-05-28 16:48:37 +02:00
2fc5812eba
RVRNEXT-11 add new endpoints for events 2023-05-28 11:29:36 +02:00
63ca6e7557
RVRNEXT-11 add model and repository for events 2023-05-28 11:29:36 +02:00
224cdc0eda
RVRNEXT-11 add migrations for events 2023-05-28 11:29:35 +02:00
42da9eb778
Merge pull request 'bugfix/fixes-for-password-reset' (!53) from bugfix/fixes-for-password-reset into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #53
2023-05-26 16:08:50 +02:00
d1dee27191
reset grecaptcha in case of error
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-08 18:48:43 +02:00
726b1818d0
fix error message for password reset request 2023-05-08 18:48:12 +02:00
75f0ae7668
Merge pull request 'feature/RVRNEXT-24-use-slugs-for-communities' (!52) from feature/RVRNEXT-24-use-slugs-for-communities into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #52
2023-05-07 01:57:57 +02:00
b43ed72040
RVRNEXT-24 replace id-based search to slug-based for communities
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-07 01:54:10 +02:00
5dc4350b20
RVRNEXT-24 modify model Community to have slug 2023-05-07 01:54:10 +02:00
829588150a
RVRNEXT-24 add migration for slug for communities 2023-05-07 01:54:10 +02:00
54d901e45e
RVRNEXT-24 update soko-web 2023-05-07 01:54:10 +02:00
5e1692efdb
Merge pull request 'feature/RVRNEXT-38-use-font-awesome' (!51) from feature/RVRNEXT-38-use-font-awesome into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #51
2023-05-06 21:52:26 +02:00
32733668ff
RVRNEXT-38 do not reference authenticateWithGoogleButton whet it does not exist
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-06 21:50:46 +02:00
3034217ff8
RVRNEXT-38 fix template for password reset
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-06 21:43:37 +02:00
c664b0a4bf
RVRNEXT-38 make it possible to show fa-icons in confirmation modals 2023-05-06 21:43:23 +02:00
f397354c1e
RVRNEXT-38 replace raw character to fa-icon in transactions 2023-05-06 21:43:01 +02:00
e3992d9d55
RVRNEXT-38 add fa-icons to buttons 2023-05-06 21:42:43 +02:00
0f7c9aa02f
RVRNEXT-38 replace svgs to fa-icons in normal layous 2023-05-06 21:41:46 +02:00
b531589c52
RVRNEXT-38 load font-awesome css 2023-05-06 21:40:56 +02:00
dc824a0f70
RVRNEXT-38 install font-awesome 2023-05-06 21:40:35 +02:00
1a06326589
Merge pull request 'feature/RVRNEXT-27-add-connect-with-google' (!50) from feature/RVRNEXT-27-add-connect-with-google into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #50
2023-05-06 19:55:16 +02:00
9780aa62cb
RVRNEXT-27 delete unused mail templates
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-06 19:49:20 +02:00
cc3cfc15c8
RVRNEXT-27 fix page header in google_login_error 2023-05-06 19:49:05 +02:00
a877484d56
RVRNEXT-27 add link to google connect/disconnect to account page 2023-05-06 19:48:54 +02:00
da5c2121ba
RVRNEXT-27 add views for google connect/disconnect 2023-05-06 19:48:03 +02:00
73c7d8434e
RVRNEXT-27 add logic for google connect/disconnect 2023-05-06 19:46:34 +02:00
3f7817fd1f
RVRNEXT-27 add new endpoints for google connect/disconnect 2023-05-06 19:46:09 +02:00
361671a362
Merge pull request 'use the DateTime object when comparing dates in ExhcnageRateCalculator' (!49) from bugfix/fix-current-exchange-rate-calculation into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #49
2023-05-06 13:44:09 +02:00
b2babcf30f
use the DateTime object when comparing dates in ExhcnageRateCalculator
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-06 13:41:17 +02:00
ea579614b3
Merge pull request 'fix margin of delete button on transaction_edit' (!48) from bugfix/layout-fix into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #48
2023-05-06 13:21:33 +02:00
d8786e74d5
fix margin of delete button on transaction_edit
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-06 13:13:15 +02:00
9e448e62c6
Merge pull request 'feature/RVRNEXT-34-use-text-labels-instead-of-placeholders-in-forms' (!47) from feature/RVRNEXT-34-use-text-labels-instead-of-placeholders-in-forms into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #47
2023-05-02 19:54:08 +02:00
7f1177cc55
RVRNEXT-34 swap delete and save in transaction_edit
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 19:53:06 +02:00
a941f5a1cd
RVRNEXT-34 remove placeholders, use labels instead 2023-05-02 19:51:15 +02:00
ba5dfdbe4d
RVRNEXT-34 add style class for form label 2023-05-02 19:49:43 +02:00
5557f00e57
Merge pull request 'RVRNEXT-36 add autocomplete values for username and password fields' (!46) from feature/RVRNEXT-36-add-autocompletes-for-usernames-and-passwords into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #46
2023-05-02 13:13:25 +02:00
baddc17b33
RVRNEXT-36 add autocomplete values for username and password fields
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 13:12:19 +02:00
8d495c9f37
Merge pull request 'feature/RVRNEXT-33-no-permanent-session' (!45) from feature/RVRNEXT-33-no-permanent-session into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #45
2023-05-02 13:03:32 +02:00
cc8f1e1ace
RVRNEXT-33 session should be valid for a session
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 12:59:45 +02:00
97e04d785c
RVRNEXT-33 adapt to new soko-web interfaces 2023-05-02 12:59:45 +02:00
0c102da1d7
RVRNEXT-33 update soko-web to 0.10 2023-05-02 12:58:18 +02:00
7f882f2e71
Merge pull request 'RVRNEXT-28 add further checks before manupulating db' (!44) from feature/RVRNEXT-28-check-foreign-key-constraints-before-db into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #44
2023-05-02 02:58:35 +02:00
cb76cbb9b1
RVRNEXT-28 add further checks before manupulating db
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 02:56:16 +02:00
1f32387f63
Merge pull request 'RVRNEXT-30 check if member already has associated transaction' (!43) from bugfix/RVRNEXT-29-prevent-deleting-member-who-has-transaction into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #43
2023-05-02 02:51:02 +02:00
be93c75fe7
RVRNEXT-30 check if member already has associated transaction
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 02:40:27 +02:00
38d18873b8
Merge pull request 'feature/RVRNEXT-30-replace-choices-to-tomselect' (!41) from feature/RVRNEXT-30-replace-choices-to-tomselect into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #41
2023-05-02 02:24:40 +02:00
5053d37388
RVRNEXT-30 initialize TomSelect in community_members.js
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 02:22:57 +02:00
c00be9a49d
RVRNEXT-30 remove unnecessary css selector 2023-05-02 02:22:35 +02:00
881280fa3e
RVRNEXT-30 use tom-select assets in community_member view 2023-05-02 02:22:10 +02:00
40eb8b5335
RVRNEXT-30 fix submit button name 2023-05-02 02:21:49 +02:00
789e9e199f
RVRNEXT-30 install tom-select instead of choices.js 2023-05-02 02:20:40 +02:00
be521a42ee
Merge pull request 'RVRNEXT-26 show confirmation modal before deleting something' (!40) from feature/RVRNEXT-26-confirmation-before-delete into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #40
2023-05-02 01:49:13 +02:00
65424c2944
RVRNEXT-26 show confirmation modal before deleting something
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 01:03:39 +02:00
490e48a87a
Merge pull request 'rename routes' (!39) from feature/rename-routes into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #39
2023-05-02 00:06:11 +02:00
b958a2f5b3
rename routes
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-02 00:02:38 +02:00
2da0ccb09b
Merge pull request 'feature/layout-improvements' (!38) from feature/layout-improvements into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #38
2023-05-01 23:42:30 +02:00
584fcaba39
make community layout clearer
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-01 23:37:30 +02:00
b5dc96f98b
fix stupid variable names 2023-05-01 23:02:57 +02:00
4dbbb1321c
Merge pull request 'feature/RVRNEXT-7-calculate-balance-for-community-members' (!37) from feature/RVRNEXT-7-calculate-balance-for-community-members into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #37
2023-05-01 22:26:03 +02:00
0a7cfdbb8e
RVRNEXT-7 show balances on community home page
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-01 22:01:11 +02:00
751c1895ca
RVRNEXT-7 calculate balances in CommunityController 2023-05-01 22:00:32 +02:00
3fd43b6e41
RVRNEXT-7 add BalanceCalculator class 2023-05-01 22:00:13 +02:00
ae3554c365
RVRNEXT-7 layout fine-tuning 2023-05-01 21:59:55 +02:00
5526a55145
RVRNEXT-7 add link for transactions 2023-05-01 19:32:35 +02:00
3e6514a2e5
Merge pull request 'feature/RVRNEXT-6-transactions' (!36) from feature/RVRNEXT-6-transactions into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #36
2023-05-01 19:28:27 +02:00
8566641459
RVRNEXT-6 add new routes for transactions
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-05-01 19:24:24 +02:00
14f7a5b9b5
RVRNEXT-6 add views for transactions 2023-05-01 19:23:48 +02:00
6fb951a01e
RVRNEXT-6 add transactioncontroller 2023-05-01 19:23:31 +02:00
c7c2df89c5
RVRNEXT-6 add ExchangeRateCalculator 2023-05-01 19:23:19 +02:00
67114211ec
RVRNEXT-6 add model and repository for transactions 2023-05-01 19:19:39 +02:00
f2d99c4f0f
RVRNEXT-6 add migration for transaction db structure 2023-05-01 19:19:10 +02:00
2ed01df331
RVRNEXT-6 layout fine-tuning 2023-05-01 19:18:48 +02:00
f89bed25da
RVRNEXT-6 update soko-web 2023-05-01 19:15:45 +02:00
d707c20ab6
Merge pull request 'feature/update-soko-web-to-0.7' (!35) from feature/update-soko-web-to-0.7 into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #35
2023-04-30 21:07:16 +02:00
49d72f1d48
adapt $withRelations usage to soko-web 0.7
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-30 20:25:21 +02:00
eabf17cb77
update soko-web to 0.7 2023-04-30 20:24:10 +02:00
7b45069904
Merge pull request 'feature/RVRNEXT-5-main-currency-of-community-should-be-a-general-currency' (!34) from feature/RVRNEXT-5-main-currency-of-community-should-be-a-general-currency into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #34
2023-04-28 21:44:18 +02:00
49df206ddc
RVRNEXT-5 move saveCommunity under getCommunityEdit
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-28 21:20:08 +02:00
e0ea18a6d0
RVRNEXT-5 migration for main_currency_id
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-28 21:06:36 +02:00
20a850f011
RVRNEXT-5 make community main currency a general currency 2023-04-28 21:06:18 +02:00
157f530ad5
RVRNEXT-5 add withRelations to CommunityMemberRepository getters 2023-04-28 20:59:14 +02:00
0b261b6272
RVRNEXT-5 fix audit logger for non-web access 2023-04-28 20:58:19 +02:00
882b326aca
Merge pull request 'merge new and edit methods in CommunityController' (!33) from feature/unify-saves-in-communitycontroller into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #33
2023-04-26 09:46:09 +02:00
49fe85dcde
merge new and edit methods in CommunityController
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-26 09:43:57 +02:00
a021aff92c
Merge pull request 'feature/RVRNEXT-5-handling-currency-exchange-rates' (!32) from feature/RVRNEXT-5-handling-currency-exchange-rates into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #32
2023-04-25 19:32:11 +02:00
6abe93e3b5
RVRNEXT-5 fine-tune styles
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-25 19:28:42 +02:00
7e84e6b4ba
RVRNEXT-5 add/update layout for currency exchange rates 2023-04-25 19:28:42 +02:00
76ed977375
RVRNEXT-5 add logic for handling currency exchange rates 2023-04-25 19:28:42 +02:00
fea403fe5a
RVRNEXT-5 add new routes for currency exchange rates 2023-04-25 19:28:42 +02:00
1d96fd0cb9
RVRNEXT-5 add model and repository for currency exchange rate 2023-04-25 19:28:42 +02:00
7437e19de5
RVRNEXT-5 add migration for currency exchange rates 2023-04-23 20:50:45 +02:00
47567c9b75
RVRNEXT-5 add links to community main page 2023-04-23 20:46:11 +02:00
ef16e70235
Merge pull request 'feature/RVRNEXT-5-handling-currencies' (!31) from feature/RVRNEXT-5-handling-currencies into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #31
2023-04-23 20:21:31 +02:00
91d064947e
RVRNEXT-5 formError should be searched in the whole document
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-23 20:20:10 +02:00
9a6e95673e
RVRNEXT-5 add formError for multi for layouts 2023-04-23 20:20:10 +02:00
d24c3bb1cb
RVRNEXT-5 move private methods to the end of CommunityController 2023-04-23 20:20:10 +02:00
2ad637e55f
RVRNEXT-5 make prepare-commit-msg accept fixup commits 2023-04-23 20:20:10 +02:00
ac09342c9d
RVRNEXT-5 fix layouts for communities 2023-04-23 20:20:10 +02:00
3a1054e1df
RVRNEXT-5 adjust table style 2023-04-23 20:20:10 +02:00
0f02c4b5be
RVRNEXT-5 update CommunityController with currency operations 2023-04-23 20:20:10 +02:00
be0a15d02d
RVRNEXT-5 extend views with currency edit 2023-04-23 20:09:49 +02:00
12065b0525
RVRNEXT-5 add model and repository for currencies 2023-04-23 20:09:44 +02:00
0fc21cc461
RVRNEXT-5 add migration for currencies 2023-04-23 20:09:44 +02:00
7d63de4403
RVRNEXT-5 add new endpoints for currency handling 2023-04-23 17:36:03 +02:00
c520c256ee
Merge pull request 'solve community member edit with forms' (!30) from feature/solve-community-member-edit-with-forms into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #30
2023-04-23 15:59:32 +02:00
2f9b97d1a4
solve community member edit with forms
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-23 15:55:31 +02:00
ad18c7cb25
Merge pull request 'use IRouteCollection in app container' (!29) from bugfix/fix-app-container into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #29
2023-04-20 00:34:26 +02:00
a2fb166b55
use IRouteCollection in app container
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-20 00:33:06 +02:00
f0a7b34212
Merge pull request 'feature/update-soko-web' (!28) from feature/update-soko-web into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #28
2023-04-19 23:43:27 +02:00
dccb34971b
adapt Container usage to new soko-web
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-19 23:38:43 +02:00
36f767ea9b
update soko-web version 2023-04-19 23:38:43 +02:00
c0739eeddf
Merge pull request 'feature/activate-audit-logging' (!27) from feature/activate-audit-logging into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #27
2023-04-19 00:58:44 +02:00
cac30c9203
add auditLogger instance to app container
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-18 23:37:55 +02:00
89b0ce768b
add db table for audit log 2023-04-18 23:37:55 +02:00
656ce219ef
add class AuditLogger 2023-04-18 23:37:55 +02:00
fd30911dd3
Merge pull request 'feature/update-to-soko-web-0.5' (!26) from feature/update-to-soko-web-0.5 into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #26
2023-04-18 23:30:33 +02:00
b4cfa7aab2
remove unnecessary startTransaction and commit calls
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-18 23:29:04 +02:00
617ae903ae
add error view 500 to app config 2023-04-18 23:29:04 +02:00
a7997429b7
add view for error 500 2023-04-18 23:26:42 +02:00
4d5e982370
pass dbConnection to HttpResponse 2023-04-18 23:26:42 +02:00
46ea01efe1
update soko-web to 0.5 2023-04-18 23:20:29 +02:00
4161abe098
Merge pull request 'fix path for antiCsrfTokenExceptions' (!25) from bugfix/fix-path-for-csrf-exceptions into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #25
2023-04-16 22:09:15 +02:00
8777d931a4
fix path for antiCsrfTokenExceptions
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-16 22:07:57 +02:00
d2ef9bb18a
Merge pull request 'remove unnecessary use in web.php' (!24) from bugfix/remove-unnecessary-use into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #24
2023-04-16 22:07:30 +02:00
3ec7fc11fa
remove unnecessary use in web.php
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-16 22:01:48 +02:00
9fd8453f63
Merge pull request 'feature/adapt-to-new-soko-web' (!23) from feature/adapt-to-new-soko-web into master
All checks were successful
rvr-nextgen/pipeline/head This commit looks good
Reviewed-on: #23
2023-04-16 21:16:58 +02:00
a7d3942d1f
use classes at beginning of web.php
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-16 21:14:56 +02:00
41933ec510
update soko-web to 0.4
All checks were successful
rvr-nextgen/pipeline/pr-master This commit looks good
2023-04-16 20:54:49 +02:00
e98bb28faf
adapt to soko-web 0.4 2023-04-16 20:54:29 +02:00
114 changed files with 4739 additions and 1818 deletions

View File

@ -21,3 +21,4 @@ RECAPTCHA_SITEKEY=your_recaptcha_sitekey
RECAPTCHA_SECRET=your_recaptcha_secret RECAPTCHA_SECRET=your_recaptcha_secret
JWT_RSA_PRIVATE_KEY=jwt-rsa256-private.pem JWT_RSA_PRIVATE_KEY=jwt-rsa256-private.pem
JWT_RSA_PUBLIC_KEY=jwt-rsa256-public.pem JWT_RSA_PUBLIC_KEY=jwt-rsa256-public.pem
JWT_KEY_KID=1

65
Jenkinsfile vendored
View File

@ -13,8 +13,9 @@ pipeline {
} }
agent { agent {
dockerfile { dockerfile {
filename 'docker/Dockerfile-test' filename 'docker/Dockerfile'
dir '.' dir '.'
additionalBuildArgs '--target rvr_base'
reuseNode true reuseNode true
} }
} }
@ -26,8 +27,9 @@ pipeline {
stage('Unit Testing') { stage('Unit Testing') {
agent { agent {
dockerfile { dockerfile {
filename 'docker/Dockerfile-test' filename 'docker/Dockerfile'
dir '.' dir '.'
additionalBuildArgs '--target rvr_base'
reuseNode true reuseNode true
} }
} }
@ -35,7 +37,7 @@ pipeline {
sh 'vendor/bin/phpunit --log-junit unit_test_results.xml --testdox tests' sh 'vendor/bin/phpunit --log-junit unit_test_results.xml --testdox tests'
} }
post { post {
success { always {
archiveArtifacts 'unit_test_results.xml' archiveArtifacts 'unit_test_results.xml'
} }
} }
@ -44,8 +46,9 @@ pipeline {
stage('Static Code Analysis') { stage('Static Code Analysis') {
agent { agent {
dockerfile { dockerfile {
filename 'docker/Dockerfile-test' filename 'docker/Dockerfile'
dir '.' dir '.'
additionalBuildArgs '--target rvr_base'
reuseNode true reuseNode true
} }
} }
@ -53,10 +56,62 @@ pipeline {
sh 'php vendor/bin/phpstan analyse -c phpstan.neon --error-format=prettyJson > static_code_analysis_results.json' sh 'php vendor/bin/phpstan analyse -c phpstan.neon --error-format=prettyJson > static_code_analysis_results.json'
} }
post { post {
success { always {
archiveArtifacts 'static_code_analysis_results.json' archiveArtifacts 'static_code_analysis_results.json'
} }
} }
} }
stage('Prepare Docker release') {
environment {
COMPOSER_HOME="${WORKSPACE}/.composer"
npm_config_cache="${WORKSPACE}/.npm"
}
agent {
dockerfile {
filename 'docker/Dockerfile'
dir '.'
additionalBuildArgs '--target rvr_base'
reuseNode true
}
}
steps {
script {
sh script: 'git clean -ffdx', label: 'Clean repository'
env.VERSION = sh(script: 'git describe --tags --always --match "Release_*" HEAD', returnStdout: true).trim()
sh script: 'docker/scripts/release.sh', label: 'Release script'
sh script: "rm -rf ${env.COMPOSER_HOME} ${env.npm_config_cache}"
}
}
}
stage('Release Docker image') {
steps {
script {
withDockerRegistry([credentialsId: 'gitea-system-user', url: 'https://git.esoko.eu/']) {
sh script: 'docker buildx create --use --bootstrap --platform=linux/arm64,linux/amd64 --name multi-platform-builder'
sh script: """docker buildx build \
--platform linux/amd64,linux/arm64 \
-f docker/Dockerfile \
--target rvr_release \
-t git.esoko.eu/esoko/rvr:${env.VERSION} \
--push \
.""",
label: 'Build Docker image'
if (env.BRANCH_NAME == 'master') {
if (env.VERSION ==~ '.*-\\d+-g[a-f0-9]{7}') {
env.FIXED_VERSION = 'dev'
} else {
env.FIXED_VERSION = 'stable'
}
sh script: """docker buildx imagetools create \
-t git.esoko.eu/esoko/rvr:${env.FIXED_VERSION} \
git.esoko.eu/esoko/rvr:${env.VERSION}"""
}
}
}
}
}
} }
} }

118
README.md
View File

@ -2,81 +2,81 @@
[![Build Status](https://ci.esoko.eu/job/rvr-nextgen/job/master/badge/icon)](https://ci.esoko.eu/job/rvr-nextgen/job/master/) [![Build Status](https://ci.esoko.eu/job/rvr-nextgen/job/master/badge/icon)](https://ci.esoko.eu/job/rvr-nextgen/job/master/)
This is the RVR Application project. This is a game about guessing where you are based on a street view panorama - inspired by existing applications. This is the RVR Application project.
## Installation ## Installation
### Clone the Git repository ### Set environment variables
The first step is obviously cloning the repository to your machine: The `.env` file contains several environment variables that are needed by the application to work properly. These should be configured for your environment. Check `.env.example` for reference.
**Important: `DEV` should NOT be set for production! See section Development if you want to use the application in development mode.**
### Docker Compose
Create a `docker-compose.yml` file. The example code below assumes that `.env` is placed in the same folder.
```yml
version: '3'
services:
app:
image: git.esoko.eu/esoko/rvr:latest
depends_on:
mariadb:
condition: service_healthy
ports:
- 80:80
volumes:
- .env:/var/www/rvr/.env
mariadb:
image: mariadb:10.3
volumes:
- mysql:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: 'root'
MYSQL_DATABASE: 'rvr'
MYSQL_USER: 'rvr'
MYSQL_PASSWORD: 'rvr'
healthcheck:
test: ["CMD-SHELL", "mysqladmin -u $$MYSQL_USER -p$$MYSQL_PASSWORD ping -h localhost || exit 1"]
start_period: 5s
start_interval: 1s
interval: 5s
timeout: 5s
retries: 5
volumes:
mysql:
```
git clone https://git.esoko.eu/esoko/rvr-nextgen.git
``` ```
All the commands listed here should be executed from the repository root. Execute the following command:
```bash
### Setup Docker stack (recommended)
The easiest way to build up a fully working application with web server and database is to use Docker Compose with the included `docker-compose.yml`.
All you have to do is executing the following command:
```
docker compose up -d docker compose up -d
``` ```
Attach shell to the container of `rvr-nextgen-app`:
``` **And you are done!** The application is ready to use. You can create the first administrative user with the following command after attaching to the `app` container:
docker exec -it rvr-nextgen-app bash
```
All of the following commands should be executed there.
### Manual setup (alternative)
If you don't use the Docker stack you need to install your environment manually. Check `docker-compose.yml` and `docker/Dockerfile` to see the system requirements.
### Initialize project
This command installes all of the Composer requirements and creates a copy of the example `.env` file.
```
composer create-project
```
### Set environment variables
The `.env` file contains several environment variables that are needed by the application to work properly. These should be configured for your environment.
One very important variable is `DEV`. This indicates that the application operates in development (staging) and not in production mode.
**Hint:** If you install the application in the Docker stack for development (staging) environment, only the variables for external dependencies (API keys, map attribution, etc.) should be adapted. All other variables (for DB connection, static root, mailing, multiplayer, etc.) are fine with the default value.
### (Production only) Create cron job
To maintain database (delete inactive users, old sessions etc.), the command `db:maintain` should be regularly executed. It is recommended to create a cron job that runs every hour:
```
0 * * * * /path/to/your/installation/rvr db:maintain >>/var/log/cron-rvr.log 2>&1
```
### Finalize installation
After you followed the above steps, execute the following command:
```
scripts/install.sh
```
**And you are done!** The application is ready to use and develop. In development mode an administrative user is also created by the installation script, email is **rvr@rvr.dev**, password is **123456**. In production mode you should create the first administrative user with the following command:
``` ```
./rvr user:add EMAIL PASSWORD admin ./rvr user:add EMAIL PASSWORD admin
``` ```
If you installed it in the Docker stack, you can reach it on http://localhost. The mails that are sent by the application can be found on http://localhost:8080/. If needed, the database server can be directly reached on localhost:3306. ## Development
### Set environment variables
`.env.example` should be copied to `.env` into the repo root. Only the variables for external dependencies (API keys, map attribution, etc.) should be adapted. All other variables (for DB connection, static root, mailing, multiplayer, etc.) are fine with the default value. **`DEV=1` should be set for development!**
### Docker Compose
Execute the following command from the repo root:
```bash
docker compose up -d
```
**And you are done!** You can reach the application on http://localhost. The mails that are sent by the application can be found on http://localhost:8080. If needed, the database server can be directly reached on localhost:3306, or you can use Adminer web interface on http://localhost:9090
You might have to attach to the `app` container, e.g. for creating users, `composer update`, etc.
--- ---

10
app.php
View File

@ -15,9 +15,13 @@ $dotenv->load();
class Container class Container
{ {
static SokoWeb\Interfaces\Database\IConnection $dbConnection; static SokoWeb\Interfaces\Database\IConnection $dbConnection;
static SokoWeb\Routing\RouteCollection $routeCollection; static SokoWeb\Interfaces\Database\IAuditLogger $auditLogger;
static SokoWeb\Interfaces\Session\ISessionHandler $sessionHandler; static SokoWeb\Interfaces\PersistentData\IPersistentDataManager $persistentDataManager;
static SokoWeb\Interfaces\Request\IRequest $request; static ?SokoWeb\Interfaces\Routing\IRouteCollection $routeCollection = null;
static ?SokoWeb\Interfaces\Session\ISessionHandler $sessionHandler = null;
static ?SokoWeb\Interfaces\Request\IRequest $request = null;
} }
Container::$dbConnection = new SokoWeb\Database\Mysql\Connection($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $_ENV['DB_NAME']); Container::$dbConnection = new SokoWeb\Database\Mysql\Connection($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $_ENV['DB_NAME']);
Container::$auditLogger = new RVR\Database\AuditLogger(Container::$dbConnection, 'audit_log');
Container::$persistentDataManager = new SokoWeb\PersistentData\PersistentDataManager(Container::$dbConnection, Container::$auditLogger);

View File

@ -10,11 +10,11 @@
} }
], ],
"require": { "require": {
"esoko/soko-web": "0.3", "esoko/soko-web": "0.15",
"firebase/php-jwt": "^6.4" "firebase/php-jwt": "^6.4"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^9.6", "phpunit/phpunit": "^10.3",
"phpstan/phpstan": "^1.10" "phpstan/phpstan": "^1.10"
}, },
"autoload": { "autoload": {

1023
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
<?php
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\Currency;
use SokoWeb\Database\Query\Select;
$select = new Select(Container::$dbConnection);
$communities = Container::$persistentDataManager->selectMultipleFromDb($select, Community::class);
foreach ($communities as $community) {
$mainCurrency = new Currency();
$mainCurrency->setCommunity($community);
$mainCurrency->setCode($community->getCurrency());
$mainCurrency->setRoundDigits(0);
Container::$persistentDataManager->saveToDb($mainCurrency);
$community->setMainCurrency($mainCurrency);
Container::$persistentDataManager->saveToDb($community);
}

View File

@ -0,0 +1,12 @@
<?php
use RVR\PersistentData\Model\Community;
use SokoWeb\Database\Query\Select;
$select = new Select(Container::$dbConnection);
$communities = Container::$persistentDataManager->selectMultipleFromDb($select, Community::class);
foreach ($communities as $community) {
$community->generateSlug();
Container::$persistentDataManager->saveToDb($community);
}

View File

@ -0,0 +1,12 @@
CREATE TABLE `audit_log` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`local_table` varchar(255) NOT NULL,
`local_id` int(10) unsigned NOT NULL,
`type` enum('insert','update','delete') NOT NULL,
`date` timestamp NOT NULL DEFAULT current_timestamp(),
`modifier_id` int(10) unsigned NULL,
`column` varchar(255) NULL,
`old` text NULL,
`new` text NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View File

@ -0,0 +1,10 @@
CREATE TABLE `currencies` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`community_id` int(10) unsigned NOT NULL,
`code` varchar(3) NOT NULL,
`round_digits` tinyint(1) unsigned NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_currency_for_community` (`community_id`, `code`),
KEY `community_id` (`community_id`),
CONSTRAINT `currencies_community_id` FOREIGN KEY (`community_id`) REFERENCES `communities` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

View File

@ -0,0 +1,10 @@
CREATE TABLE `currency_exchange_rates` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`currency_id` int(10) unsigned NOT NULL,
`exchange_rate` decimal(19,9) unsigned NOT NULL,
`valid_from` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `currency_id` (`currency_id`),
INDEX `valid_from` (`valid_from`),
CONSTRAINT `currency_exchange_rates_currency_id` FOREIGN KEY (`currency_id`) REFERENCES `currencies` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

View File

@ -0,0 +1,4 @@
ALTER TABLE `communities`
ADD `main_currency_id` int(10) unsigned DEFAULT NULL,
ADD KEY `main_currency_id` (`main_currency_id`),
ADD CONSTRAINT `communities_main_currency_id` FOREIGN KEY (`main_currency_id`) REFERENCES `currencies` (`id`);

View File

@ -0,0 +1,19 @@
CREATE TABLE `transactions` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`community_id` int(10) unsigned NOT NULL,
`currency_id` int(10) unsigned NOT NULL,
`payer_user_id` int(10) unsigned NOT NULL,
`payee_user_id` int(10) unsigned NULL,
`description` varchar(255) NOT NULL,
`sum` decimal(19,9) NOT NULL,
`time` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `community_id` (`community_id`),
KEY `currency_id` (`currency_id`),
KEY `payer_user_id` (`payer_user_id`),
KEY `payee_user_id` (`payee_user_id`),
CONSTRAINT `transactions_community_id` FOREIGN KEY (`community_id`) REFERENCES `communities` (`id`),
CONSTRAINT `transactions_currency_id` FOREIGN KEY (`currency_id`) REFERENCES `currencies` (`id`),
CONSTRAINT `transactions_payer_user_id` FOREIGN KEY (`payer_user_id`) REFERENCES `users` (`id`),
CONSTRAINT `transactions_payee_user_id` FOREIGN KEY (`payee_user_id`) REFERENCES `users` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

View File

@ -0,0 +1,3 @@
ALTER TABLE `communities`
ADD `slug` varchar(255) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL AFTER `id`,
ADD UNIQUE `slug` (`slug`);

View File

@ -0,0 +1,13 @@
CREATE TABLE `events` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`slug` varchar(255) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL,
`community_id` int(10) unsigned NOT NULL,
`start` timestamp NOT NULL DEFAULT current_timestamp(),
`end` timestamp NOT NULL DEFAULT current_timestamp(),
`title` varchar(255) NOT NULL,
`description` text NOT NULL,
PRIMARY KEY (`id`),
KEY `community_id` (`community_id`),
UNIQUE KEY `slug` (`slug`),
CONSTRAINT `events_community_id` FOREIGN KEY (`community_id`) REFERENCES `communities` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

View File

@ -0,0 +1,4 @@
ALTER TABLE `transactions`
ADD `event_id` int(10) unsigned NULL AFTER `community_id`,
ADD KEY `event_id` (`event_id`),
ADD CONSTRAINT `transactions_event_id` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`);

View File

@ -0,0 +1,11 @@
CREATE TABLE `transaction_payees` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`transaction_id` int(10) unsigned NOT NULL,
`user_id` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_for_transaction` (`transaction_id`, `user_id`),
KEY `transaction_id` (`transaction_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `transaction_payees_transaction_id` FOREIGN KEY (`transaction_id`) REFERENCES `transactions` (`id`),
CONSTRAINT `transaction_payees_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

View File

@ -0,0 +1,26 @@
CREATE TABLE `oauth_sessions` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`client_id` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`scope` varchar(255) NOT NULL DEFAULT '',
`nonce` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`user_id` int(10) unsigned DEFAULT NULL,
`code` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`code_challenge` varchar(128) NULL,
`code_challenge_method` enum('plain', 'S256') NULL,
`token_claimed` tinyint(1) NOT NULL DEFAULT 0,
`created` timestamp NOT NULL DEFAULT current_timestamp(),
`expires` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
ALTER TABLE `oauth_tokens`
ADD `session_id` int(10) unsigned NULL,
ADD CONSTRAINT `oauth_tokens_session_id` FOREIGN KEY (`session_id`) REFERENCES `oauth_sessions` (`id`),
DROP INDEX `code`,
DROP INDEX `access_token`,
DROP `scope`,
DROP `nonce`,
DROP `user_id`,
DROP `code`,
DROP `access_token`;

View File

@ -2,12 +2,17 @@ version: '3'
services: services:
app: app:
build: build:
context: ./docker context: .
dockerfile: Dockerfile-app dockerfile: docker/Dockerfile
target: rvr_dev
depends_on:
mariadb:
condition: service_healthy
ports: ports:
- 80:80 - 80:80
volumes: volumes:
- .:/var/www/rvr - .:/var/www/rvr
working_dir: /var/www/rvr
mariadb: mariadb:
image: mariadb:10.3 image: mariadb:10.3
ports: ports:
@ -19,6 +24,13 @@ services:
MYSQL_DATABASE: 'rvr' MYSQL_DATABASE: 'rvr'
MYSQL_USER: 'rvr' MYSQL_USER: 'rvr'
MYSQL_PASSWORD: 'rvr' MYSQL_PASSWORD: 'rvr'
healthcheck:
test: ["CMD-SHELL", "mysqladmin -u $$MYSQL_USER -p$$MYSQL_PASSWORD ping -h localhost || exit 1"]
start_period: 5s
start_interval: 1s
interval: 5s
timeout: 5s
retries: 5
adminer: adminer:
image: adminer:4.8.1-standalone image: adminer:4.8.1-standalone
ports: ports:

44
docker/Dockerfile Normal file
View File

@ -0,0 +1,44 @@
FROM ubuntu:22.04 AS rvr_base
ENV DEBIAN_FRONTEND noninteractive
RUN apt update --fix-missing && apt install -y sudo curl git unzip mariadb-client nginx \
php-apcu php8.1-cli php8.1-curl php8.1-fpm php8.1-mbstring php8.1-mysql php8.1-zip php8.1-xml
RUN mkdir -p /run/php
COPY docker/configs/nginx.conf /etc/nginx/sites-available/default
COPY docker/scripts/install-composer.sh install-composer.sh
RUN ./install-composer.sh
COPY docker/scripts/install-nodejs.sh install-nodejs.sh
RUN ./install-nodejs.sh
RUN npm install -g uglify-js clean-css-cli svgo yarn
FROM rvr_base AS rvr_dev
RUN apt update --fix-missing && apt install -y php-xdebug
RUN echo "xdebug.remote_enable = 1" >> /etc/php/8.1/mods-available/xdebug.ini &&\
echo "xdebug.remote_autostart = 1" >> /etc/php/8.1/mods-available/xdebug.ini &&\
echo "xdebug.remote_connect_back = 1" >> /etc/php/8.1/mods-available/xdebug.ini
EXPOSE 80
EXPOSE 5000
EXPOSE 8090
EXPOSE 9229
ENTRYPOINT docker/scripts/entry-point-dev.sh
FROM rvr_base AS rvr_release
RUN apt update --fix-missing && apt install -y cron
WORKDIR /var/www/rvr
COPY ./ /var/www/rvr
RUN rm -rf /var/www/rvr/.git
EXPOSE 80
EXPOSE 8090
ENTRYPOINT docker/scripts/entry-point.sh

View File

@ -1,30 +0,0 @@
FROM ubuntu:focal
ENV DEBIAN_FRONTEND noninteractive
# Install Nginx, PHP and further necessary packages
RUN apt update --fix-missing
RUN apt install -y curl git unzip mariadb-client nginx \
php-apcu php-xdebug php7.4-cli php7.4-curl php7.4-fpm php7.4-mbstring php7.4-mysql php7.4-zip php7.4-xml
# Configure Nginx with PHP
RUN mkdir -p /run/php
COPY configs/nginx.conf /etc/nginx/sites-available/default
RUN echo "xdebug.remote_enable = 1" >> /etc/php/7.4/mods-available/xdebug.ini
RUN echo "xdebug.remote_autostart = 1" >> /etc/php/7.4/mods-available/xdebug.ini
RUN echo "xdebug.remote_connect_back = 1" >> /etc/php/7.4/mods-available/xdebug.ini
# Install Composer
COPY scripts/install-composer.sh install-composer.sh
RUN ./install-composer.sh
# Install Node.js and required packages
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash -
RUN apt install -y nodejs
RUN npm install -g uglify-js clean-css-cli svgo yarn
EXPOSE 80
VOLUME /var/www/rvr
WORKDIR /var/www/rvr
ENTRYPOINT /usr/sbin/php-fpm7.4 -F & /usr/sbin/nginx -g 'daemon off;'

View File

@ -1,6 +0,0 @@
FROM ubuntu:focal
ENV DEBIAN_FRONTEND noninteractive
RUN apt update && apt install -y curl git unzip php7.4-cli php7.4-mbstring php7.4-xml
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

View File

@ -1,3 +1,9 @@
map $http_x_forwarded_proto $forwarded_scheme {
default $scheme;
http http;
https https;
}
server { server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
@ -14,7 +20,8 @@ server {
location ~ \.php$ { location ~ \.php$ {
include snippets/fastcgi-php.conf; include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param REQUEST_SCHEME $forwarded_scheme;
} }
location ~ /\.ht { location ~ /\.ht {

1
docker/scripts/cron Normal file
View File

@ -0,0 +1 @@
0 * * * * /var/www/rvr/rvr db:maintain

View File

@ -0,0 +1,36 @@
#!/bin/bash
set -e
echo "Installing Composer packages..."
if [ -f .env ]; then
composer install
else
composer create-project
fi
echo "Installing Yarn packages..."
(cd public/static && yarn install)
echo "Migrating DB..."
./rvr db:migrate
echo "Set runner user based on owner of .env..."
if ! getent group rvr; then
USER_GID=$(stat -c "%g" .env)
groupadd --gid $USER_GID rvr
fi
if ! id -u rvr; then
USER_UID=$(stat -c "%u" .env)
useradd --uid $USER_UID --gid $USER_GID rvr
fi
sed -i -e "s/^user = .*$/user = rvr/g" -e "s/^group = .*$/group = rvr/g" /etc/php/8.1/fpm/pool.d/www.conf
set +e
/usr/sbin/php-fpm8.1 -F &
/usr/sbin/nginx -g 'daemon off;' &
wait -n
exit $?

31
docker/scripts/entry-point.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/bash
set -e
echo "Migrating DB..."
./rvr db:migrate
echo "Installing crontab..."
/usr/bin/crontab docker/scripts/cron
echo "Set runner user based on owner of .env..."
if ! getent group rvr; then
USER_GID=$(stat -c "%g" .env)
groupadd --gid $USER_GID rvr
fi
if ! id -u rvr; then
USER_UID=$(stat -c "%u" .env)
useradd --uid $USER_UID --gid $USER_GID rvr
fi
chown -R rvr:rvr cache
sed -i -e "s/^user = .*$/user = rvr/g" -e "s/^group = .*$/group = rvr/g" /etc/php/8.1/fpm/pool.d/www.conf
set +e
/usr/sbin/cron -f &
/usr/sbin/php-fpm8.1 -F &
/usr/sbin/nginx -g 'daemon off;' &
wait -n
exit $?

View File

@ -0,0 +1,14 @@
#!/bin/sh
set -e
apt update
apt install -y ca-certificates curl gnupg
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=18
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
apt update
apt install -y nodejs

27
docker/scripts/release.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/bash
set -e
echo "Installing Composer packages..."
composer create-project --no-dev
echo "Installing Yarn packages..."
(cd public/static && yarn install)
echo "Updating version info..."
VERSION=$(git describe --tags --always --match "Release_*" HEAD)
REVISION=$(git rev-parse --short HEAD)
REVISION_DATE=$(git show -s --format=%aI HEAD)
sed -i -E "s/const VERSION = '(.*)';/const VERSION = '${VERSION}';/" app.php
sed -i -E "s/const REVISION = '(.*)';/const REVISION = '${REVISION}';/" app.php
sed -i -E "s/const REVISION_DATE = '(.*)';/const REVISION_DATE = '${REVISION_DATE}';/" app.php
echo "Minifying JS, CSS and SVG files..."
find public/static/js -type f -iname '*.js' -exec uglifyjs {} -c -m -o {} \;
find public/static/css -type f -iname '*.css' -exec cleancss {} -o {} \;
find public/static/img -type f -iname '*.svg' -exec svgo {} -o {} \;
echo "Linking view files..."
./rvr view:link
rm .env

View File

@ -1,9 +0,0 @@
Hi,
<br><br>
You recently signed up on {{APP_NAME}} with this Google account ({{EMAIL}}).
<br><br>
Have fun on {{APP_NAME}}!
<br><br>
Regards,<br>
{{APP_NAME}}<br>
<a href="{{BASE_URL}}" title="{{APP_NAME}}">{{BASE_URL}}</a>

View File

@ -1,18 +0,0 @@
Hi,
<br><br>
You recently signed up on {{APP_NAME}} with this email address ({{EMAIL}}).
To activate your account, please click on the following link:<br>
<a href="{{ACTIVATE_LINK}}" title="Account activation">{{ACTIVATE_LINK}}</a>
<br><br>
You can activate your account until {{ACTIVATABLE_UNTIL}}.
If you don't activate your account, your email address will be permanently deleted after this point of time.
<br><br>
If you did not sign up on {{APP_NAME}} or changed your mind, no further action is required.
However if you want to immediately delete your email address, please click on the following link:<br>
<a href="{{CANCEL_LINK}}" title="Sign up cancellation">{{CANCEL_LINK}}</a>
<br><br>
Have fun on {{APP_NAME}}!
<br><br>
Regards,<br>
{{APP_NAME}}<br>
<a href="{{BASE_URL}}" title="{{APP_NAME}}">{{BASE_URL}}</a>

View File

@ -1,66 +1,3 @@
<?php <?php
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Authorization\ISecured;
use SokoWeb\Response\Redirect;
use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\JsonContent;
require '../web.php'; require '../web.php';
$method = strtolower($_SERVER['REQUEST_METHOD']);
$url = substr($_SERVER['REQUEST_URI'], strlen('/'));
if (($pos = strpos($url, '?')) !== false) {
$url = substr($url, 0, $pos);
}
$url = rawurldecode($url);
$match = Container::$routeCollection->match($method, $url == '' ? [] : explode('/', $url));
if ($match !== null) {
list($route, $params) = $match;
Container::$request->setParsedRouteParams($params);
$handler = $route->getHandler();
$controller = new $handler[0](Container::$request);
if (
$controller instanceof IAuthenticationRequired &&
$controller->isAuthenticationRequired() &&
Container::$request->user() === null
) {
Container::$request->session()->set('redirect_after_login', substr($_SERVER['REQUEST_URI'], strlen('/')));
$response = new Redirect(Container::$routeCollection->getRoute('login')->generateLink(), IRedirect::TEMPORARY);
header('Location: ' . $response->getUrl(), true, $response->getHttpCode());
return;
}
if ($method === 'post' && !in_array($url, $antiCsrfTokenExceptions) && Container::$request->post('anti_csrf_token') !== Container::$request->session()->get('anti_csrf_token')) {
$content = new JsonContent(['error' => 'no_valid_anti_csrf_token']);
header('Content-Type: text/html; charset=UTF-8', true, 403);
$content->render();
return;
}
if (
!($controller instanceof ISecured) ||
$controller->authorize()
) {
$response = call_user_func([$controller, $handler[1]]);
if ($response instanceof IContent) {
header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8');
$response->render();
return;
} elseif ($response instanceof IRedirect) {
header('Location: ' . $response->getUrl(), true, $response->getHttpCode());
return;
}
}
}
$content = new HtmlContent('error/404');
header('Content-Type: text/html; charset=UTF-8', true, 404);
$content->render();

View File

@ -31,6 +31,10 @@ main {
color: #ffffff; color: #ffffff;
} }
::placeholder, select > option[value=""], .gray {
color: #8e8e8e;
}
p, h1, h2, h3, input, textarea, select, button, a, table, label { p, h1, h2, h3, input, textarea, select, button, a, table, label {
font-family: 'Oxygen', sans-serif; font-family: 'Oxygen', sans-serif;
} }
@ -63,7 +67,7 @@ p, h2, h3 {
line-height: 150%; line-height: 150%;
} }
p { p, th, td {
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 16px;
} }
@ -100,10 +104,27 @@ hr {
font-weight: 700; font-weight: 700;
} }
p.small, span.small { p.small, span.small, td.small {
font-size: 14px; font-size: 14px;
} }
p.big, span.big, td.big {
font-size: 18px;
}
.red {
color: #a80908;
}
.green {
color: #008000;
}
.mono {
font-family: 'Oxygen Mono', mono;
font-weight: 400;
}
.justify { .justify {
text-align: justify; text-align: justify;
} }
@ -113,7 +134,7 @@ p.small, span.small {
} }
.marginLeft { .marginLeft {
margin-left: 10px; margin-left: 5px;
} }
.marginBottom { .marginBottom {
@ -121,7 +142,7 @@ p.small, span.small {
} }
.marginRight { .marginRight {
margin-right: 10px; margin-right: 5px;
} }
.center { .center {
@ -150,6 +171,12 @@ a:hover, a:focus {
text-decoration: underline; text-decoration: underline;
} }
a.block {
color: initial;
font-weight: initial;
text-decoration: initial;
}
button, a.button { button, a.button {
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 16px;
@ -215,6 +242,7 @@ button.noRightRadius, a.button.noRightRadius {
button.gray, a.button.gray { button.gray, a.button.gray {
background-color: #808080; background-color: #808080;
color: #ffffff;
} }
button.gray:enabled:hover, button.gray:enabled:focus, a.button.gray:hover, a.button.gray:focus { button.gray:enabled:hover, button.gray:enabled:focus, a.button.gray:hover, a.button.gray:focus {
@ -223,6 +251,7 @@ button.gray:enabled:hover, button.gray:enabled:focus, a.button.gray:hover, a.but
button.red, a.button.red { button.red, a.button.red {
background-color: #aa5e5e; background-color: #aa5e5e;
color: #ffffff;
} }
button.red:enabled:hover, button.red:enabled:focus, a.button.red:hover, a.button.red:focus { button.red:enabled:hover, button.red:enabled:focus, a.button.red:hover, a.button.red:focus {
@ -230,11 +259,11 @@ button.red:enabled:hover, button.red:enabled:focus, a.button.red:hover, a.button
} }
button.yellow, a.button.yellow { button.yellow, a.button.yellow {
background-color: #e8a349; background-color: #daa520;
} }
button.yellow:enabled:hover, button.yellow:enabled:focus, a.button.yellow:hover, a.button.yellow:focus { button.yellow:enabled:hover, button.yellow:enabled:focus, a.button.yellow:hover, a.button.yellow:focus {
background-color: #c37713; background-color: #b8860b;
} }
button.green, a.button.green { button.green, a.button.green {
@ -372,16 +401,20 @@ header>p {
} }
header>p>span { header>p>span {
padding-left: 6px; padding-left: 8px;
} }
header>p>span>a:link, header>p>span>a:visited { header>p>span>a:link, header>p>span>a:visited {
color: inherit; color: inherit;
} }
header>p>span>a:hover, header>p>span>a:focus {
text-decoration: none;
}
header>p>span:not(:last-child) { header>p>span:not(:last-child) {
border-right: solid white 1px; border-right: solid white 1px;
padding-right: 6px; padding-right: 8px;
} }
main { main {
@ -421,7 +454,6 @@ div.buttonContainer>button {
} }
div.box { div.box {
width: 576px;
background-color: #eeeef4; background-color: #eeeef4;
border-radius: 3px; border-radius: 3px;
margin: 10px auto; margin: 10px auto;
@ -429,6 +461,15 @@ div.box {
box-sizing: border-box; box-sizing: border-box;
} }
div.compactBox {
width: 576px;
}
div.transaction {
display: grid;
grid-template-columns: auto auto;
}
div.gridContainer { div.gridContainer {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@ -451,16 +492,62 @@ table.fullWidth {
} }
table th { table th {
font-weight: bold; font-weight: 700;
} }
table th, table td { table th, table td {
padding: 2px 0; padding: 3px 0;
vertical-align: middle; vertical-align: middle;
} }
.choices__inner { table th:not(:first-child), table td:not(:first-child) {
box-sizing: border-box; padding-left: 3px;
}
table th:not(:last-child), table td:not(:last-child) {
padding-right: 3px;
}
p.paginateContainer {
font-size: 0;
}
p.paginateContainer > * {
font-size: initial;
background-color: #5e77aa;
border: solid #5e77aa 1px;
color: #ffffff;
font-weight: 700;
padding: 3px 6px;
text-align: center;
display: inline-block;
height: 25px;
line-height: 25px;
width: 25px;
}
p.paginateContainer > a:hover, p.paginateContainer > .selected {
background-color: #3b5998;
border: solid #29457f 1px;
text-decoration: none;
}
p.paginateContainer > *:not(:last-child) {
border-right: solid #869ab9 1px;
}
p.formLabel {
font-weight: 700;
font-size: 14px;
}
span.label {
border-radius: 3px;
background-color: #555555;
color: #ffffff;
padding: 2px 4px;
font-weight: bold;
font-size: 13px;
} }
@media screen and (max-width: 424px) { @media screen and (max-width: 424px) {
@ -477,6 +564,7 @@ table th, table td {
margin-top: 4px; margin-top: 4px;
} }
button, a.button { button, a.button {
margin: 3px 0;
padding: 0; padding: 0;
width: 100%; width: 100%;
} }
@ -492,7 +580,7 @@ table th, table td {
padding-left: 15px; padding-left: 15px;
padding-right: 15px; padding-right: 15px;
} }
div.box { div.compactBox {
width: initial; width: initial;
} }
} }

View File

@ -59,11 +59,10 @@ var Account = {
}; };
(function () { (function () {
document.getElementById('authenticateWithGoogleButton').onclick = function () { var authenticateWithGoogleButton = document.getElementById('authenticateWithGoogleButton');
Account.openGoogleAuthenticate(); if (authenticateWithGoogleButton) {
}; authenticateWithGoogleButton.onclick = function () {
Account.openGoogleAuthenticate();
document.getElementsByTagName('form')[0].onreset = function () { };
Account.resetGoogleAuthentication(); }
};
})(); })();

View File

@ -1,62 +1,30 @@
(function () { (function () {
const element = document.getElementById('new_member_user_id'); const element = document.getElementById('newMember').elements['user_id'];
const choices = new Choices(element, { const select = new TomSelect(element, {
noResultsText: 'No users found', valueField: 'value',
noChoicesText: 'Start typing to search users' labelField: 'label',
searchField: 'label',
loadThrottle: 300,
load: function (query, callback) {
var self = this;
RVR.httpRequest('GET', searchUserUrl.replace('QUERY', encodeURIComponent(query)), function () {
self.clearOptions();
callback(this.response.results);
});
},
}); });
element.addEventListener('search', RVR.debounce(async function (e) { select.on('change', function (value) {
RVR.httpRequest('GET', searchUserUrl.replace('QUERY', encodeURIComponent(e.detail.value)), function () { this.clearOptions();
choices.setChoices(this.response.results, 'value', 'label', true);
});
}));
element.addEventListener('choice', function () {
choices.setChoices([], 'value', 'label', true);
document.getElementById('new_member_button').disabled = false;
}); });
document.getElementById('new_member_button').addEventListener('click', function () { select.on('blur', function (value) {
document.getElementById('loading').style.visibility = 'visible'; this.clearOptions();
let data = new FormData();
data.append('user_id', document.getElementById('new_member_user_id').value);
RVR.httpRequest('POST', newMemberUrl, function () {
window.location.reload();
}, data);
}); });
const ownerCheckboxesButtons = document.getElementsByClassName('member_owner'); select.on('type', function (value) {
for (const ownerCheckboxesButton of ownerCheckboxesButtons) { if (value === '') {
ownerCheckboxesButton.addEventListener('change', function () { this.clearOptions();
document.getElementById('loading').style.visibility = 'visible'; }
});
let data = new FormData();
data.append('community_member_id', this.dataset.id);
data.append('owner', this.checked ? 1 : 0);
RVR.httpRequest('POST', editMemberUrl, function () {
document.getElementById('loading').style.visibility = 'hidden';
if (!this.response.success) {
ownerCheckboxesButton.checked = !ownerCheckboxesButton.checked;
}
}, data);
});
};
const deleteButtons = document.getElementsByClassName('delete_member');
for (const deleteButton of deleteButtons) {
deleteButton.addEventListener('click', function () {
document.getElementById('loading').style.visibility = 'visible';
let data = new FormData();
data.append('community_member_id', this.dataset.id);
RVR.httpRequest('POST', deleteMemberUrl, function () {
window.location.reload();
}, data);
});
};
})(); })();

View File

@ -0,0 +1,30 @@
(function () {
const element = document.getElementById('transactionForm').elements['event_id'];
const select = new TomSelect(element, {
valueField: 'value',
labelField: 'label',
searchField: 'label',
loadThrottle: 300,
load: function (query, callback) {
var self = this;
RVR.httpRequest('GET', searchEventUrl.replace('QUERY', encodeURIComponent(query)), function () {
self.clearOptions();
callback(this.response.results);
});
},
});
select.on('change', function (value) {
this.clearOptions();
});
select.on('blur', function (value) {
this.clearOptions();
});
select.on('type', function (value) {
if (value === '') {
this.clearOptions();
}
});
})();

View File

@ -49,7 +49,7 @@ var RVR = {
document.getElementById('loading').style.visibility = 'visible'; document.getElementById('loading').style.visibility = 'visible';
var formData = new FormData(form); var formData = new FormData(form);
var formError = form.getElementsByClassName('formError')[0]; var formError = document.getElementsByClassName('formError')[0];
var pageLeaveOnSuccess = form.dataset.redirectOnSuccess || form.dataset.reloadOnSuccess; var pageLeaveOnSuccess = form.dataset.redirectOnSuccess || form.dataset.reloadOnSuccess;
RVR.httpRequest('POST', form.action, function () { RVR.httpRequest('POST', form.action, function () {
@ -64,6 +64,9 @@ var RVR = {
formError.style.display = 'block'; formError.style.display = 'block';
formError.innerHTML = this.response.error.errorText; formError.innerHTML = this.response.error.errorText;
if (typeof grecaptcha !== 'undefined') {
grecaptcha.reset();
}
return; return;
} }
@ -88,6 +91,28 @@ var RVR = {
} }
}, },
setOnclickForFormConfirmation: function (button) {
button.onclick = function(e) {
e.preventDefault();
var self = this;
RVR.showModalWithContent('Confirmation', this.dataset.confirmation, [
{
type: 'button',
html: this.dataset.confirmationButton ? this.dataset.confirmationButton : this.innerHTML,
classNames: ['red'],
onclick: function() {
var event = new Event('submit', {'bubbles': true, 'cancelable': true});
self.form.dispatchEvent(event);
RVR.hideModal();
}
}
]);
};
},
showModal: function (id) { showModal: function (id) {
document.getElementById(id).style.visibility = 'visible'; document.getElementById(id).style.visibility = 'visible';
document.getElementById('cover').style.visibility = 'visible'; document.getElementById('cover').style.visibility = 'visible';
@ -125,7 +150,11 @@ var RVR = {
button.classList.add('marginTop'); button.classList.add('marginTop');
button.classList.add('marginRight'); button.classList.add('marginRight');
button.textContent = extraButton.text; if (typeof extraButton.html !== 'undefined') {
button.innerHTML = extraButton.html;
} else {
button.textContent = extraButton.text;
}
if (extraButton.type === 'a') { if (extraButton.type === 'a') {
button.href = extraButton.href; button.href = extraButton.href;
@ -163,12 +192,18 @@ var RVR = {
for (var i = 0; i < observedInputs.length; i++) { for (var i = 0; i < observedInputs.length; i++) {
var input = form.elements[observedInputs[i]]; var input = form.elements[observedInputs[i]];
if (input.defaultValue !== input.value) { if (input.type === 'checkbox') {
anyChanged = true; if (input.defaultChecked !== input.checked) {
anyChanged = true;
}
} else {
if (input.defaultValue !== input.value) {
anyChanged = true;
}
} }
} }
form.elements.submit.disabled = !anyChanged; form.elements['submit_button'].disabled = !anyChanged;
}, },
observeInputsInForm: function (form, observedInputs) { observeInputsInForm: function (form, observedInputs) {
@ -191,7 +226,7 @@ var RVR = {
} }
form.onreset = function () { form.onreset = function () {
form.elements.submit.disabled = true; form.elements['submit_button'].disabled = true;
} }
}, },
@ -228,6 +263,10 @@ var RVR = {
if (form.dataset.observeInputs) { if (form.dataset.observeInputs) {
RVR.observeInputsInForm(form, form.dataset.observeInputs.split(',')); RVR.observeInputsInForm(form, form.dataset.observeInputs.split(','));
} }
if (form.elements['submit_button'] && form.elements['submit_button'].dataset.confirmation) {
RVR.setOnclickForFormConfirmation(form.elements['submit_button']);
}
} }
document.getElementById('cover').onclick = function () { document.getElementById('cover').onclick = function () {

View File

@ -2,33 +2,23 @@
"requires": true, "requires": true,
"lockfileVersion": 1, "lockfileVersion": 1,
"dependencies": { "dependencies": {
"@babel/runtime": { "@fortawesome/fontawesome-free": {
"version": "7.21.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.0.tgz",
"integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", "integrity": "sha512-0NyytTlPJwB/BF5LtRV8rrABDbe3TdTXqNB3PdZ+UUUZAEIrdOJdmABqKjt4AXwIoJNaRVVZEXxpNrqvE1GAYQ=="
},
"@orchidjs/sifter": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz",
"integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==",
"requires": { "requires": {
"regenerator-runtime": "^0.13.11" "@orchidjs/unicode-variants": "^1.0.4"
} }
}, },
"choices.js": { "@orchidjs/unicode-variants": {
"version": "10.2.0", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/choices.js/-/choices.js-10.2.0.tgz", "resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz",
"integrity": "sha512-8PKy6wq7BMjNwDTZwr3+Zry6G2+opJaAJDDA/j3yxvqSCnvkKe7ZIFfIyOhoc7htIWFhsfzF9tJpGUATcpUtPg==", "integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ=="
"requires": {
"deepmerge": "^4.2.2",
"fuse.js": "^6.6.2",
"redux": "^4.2.0"
}
},
"deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
},
"fuse.js": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
"integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA=="
}, },
"leaflet": { "leaflet": {
"version": "1.9.3", "version": "1.9.3",
@ -40,18 +30,14 @@
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==" "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA=="
}, },
"redux": { "tom-select": {
"version": "4.2.1", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.2.2.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "integrity": "sha512-igGah1yY6yhrnN2h/Ky8I5muw/nE/YQxIsEZoYu5qaA4bsRibvKto3s8QZZosKpOd0uO8fNYhRfAwgHB4IAYew==",
"requires": { "requires": {
"@babel/runtime": "^7.9.2" "@orchidjs/sifter": "^1.0.3",
"@orchidjs/unicode-variants": "^1.0.4"
} }
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
} }
} }
} }

View File

@ -1,7 +1,8 @@
{ {
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
"leaflet": "^1.6.0", "leaflet": "^1.6.0",
"leaflet.markercluster": "^1.4.1", "leaflet.markercluster": "^1.4.1",
"choices.js": "^10.2.0" "tom-select": "^2.2.2"
} }
} }

View File

@ -2,6 +2,23 @@
# yarn lockfile v1 # yarn lockfile v1
"@fortawesome/fontawesome-free@^6.4.0":
version "6.7.1"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.1.tgz#160a48730d533ec77578ed0141661a8f0150a71d"
integrity sha512-ALIk/MOh5gYe1TG/ieS5mVUsk7VUIJTJKPMK9rFFqOgfp0Q3d5QiBXbcOMwUvs37fyZVCz46YjOE6IFeOAXCHA==
"@orchidjs/sifter@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.1.0.tgz#b36154ad0cda4898305d1ac44f318b41048a0438"
integrity sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==
dependencies:
"@orchidjs/unicode-variants" "^1.1.2"
"@orchidjs/unicode-variants@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz#1fd71791a67fdd1591ebe0dcaadd3964537a824e"
integrity sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==
leaflet.markercluster@^1.4.1: leaflet.markercluster@^1.4.1:
version "1.4.1" version "1.4.1"
resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.4.1.tgz#b53f2c4f2ca7306ddab1dbb6f1861d5e8aa6c5e5" resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.4.1.tgz#b53f2c4f2ca7306ddab1dbb6f1861d5e8aa6c5e5"
@ -11,3 +28,11 @@ leaflet@^1.6.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.6.0.tgz#aecbb044b949ec29469eeb31c77a88e2f448f308" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.6.0.tgz#aecbb044b949ec29469eeb31c77a88e2f448f308"
integrity sha512-CPkhyqWUKZKFJ6K8umN5/D2wrJ2+/8UIpXppY7QDnUZW5bZL5+SEI2J7GBpwh4LIupOKqbNSQXgqmrEJopHVNQ== integrity sha512-CPkhyqWUKZKFJ6K8umN5/D2wrJ2+/8UIpXppY7QDnUZW5bZL5+SEI2J7GBpwh4LIupOKqbNSQXgqmrEJopHVNQ==
tom-select@^2.2.2:
version "2.4.1"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.1.tgz#6a0b6df8af3df7b09b22dd965eb75ce4d1c547bc"
integrity sha512-adI8H8+wk8RRzHYLQ3bXSk2Q+FAq/kzAATrcWlJ2fbIrEzb0VkwaXzKHTAlBwSJrhqbPJvhV/0eypFkED/nAug==
dependencies:
"@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.1.2"

View File

@ -1,162 +0,0 @@
#!/usr/bin/python3
# Usage: ./deploy-to-multiple-worktrees.py REPO_PATH WORKTREE_DEVELOPMENT_PATH WORKTREE_PRODUCTION_PATH
import sys
import os
import subprocess
import re
WORKTREE_REGEX = r"^worktree (.*)\nHEAD ([a-f0-9]*)\n(?:branch refs\/heads\/(.*)|detached)$"
if len(sys.argv) < 4:
print("Usage: ./deploy-to-multiple-worktrees.py REPO_PATH WORKTREE_DEVELOPMENT_PATH WORKTREE_PRODUCTION_PATH")
exit(1)
REPO = os.path.abspath(sys.argv[1])
WORKTREE_DEVELOPMENT = os.path.abspath(sys.argv[2])
WORKTREE_PRODUCTION = os.path.abspath(sys.argv[3])
class Worktree:
def __init__(self, path, branch, revision, version):
self.path = path
self.branch = branch
self.revision = revision
self.version = version
self.newRevision = None
self.newVersion = None
def getDataForWorktrees():
ret = subprocess.check_output(["git", "worktree", "list", "--porcelain"], cwd=REPO).decode().strip()
blocks = ret.split("\n\n")
worktrees = []
for block in blocks:
matches = re.search(WORKTREE_REGEX, block)
if matches:
path = matches.group(1)
revision = matches.group(2)
branch = matches.group(3)
version = getVersion(revision)
worktrees.append(Worktree(path, branch, revision, version))
return worktrees
def findWorktree(path):
for worktree in worktrees:
if worktree.path == path:
return worktree
return None
def getVersion(branch):
return subprocess.check_output(["git", "describe", "--tags", "--always", "--match", "Release_*", branch], cwd=REPO).decode().strip()
def getRevisionForRef(ref):
return subprocess.check_output(["git", "rev-list", "-1", ref], cwd=REPO).decode().strip()
def getLatestReleaseTag():
process = subprocess.Popen(["git", "for-each-ref", "refs/tags/Release*", "--sort=-creatordate", "--format=%(refname:short)"], stdout=subprocess.PIPE, cwd=REPO)
for line in process.stdout:
tag = line.decode().rstrip()
if isTagVerified(tag):
return tag
print(f"[WARNING] Tag '{tag}' is not verified, skipping.")
raise Exception("No verified 'Release*' tag found!")
def isTagVerified(tag):
process = subprocess.run(["git", "tag", "--verify", tag], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=REPO)
return process.returncode == 0
def updateRepoFromRemote():
subprocess.call(["git", "fetch", "origin", "--prune", "--prune-tags"], cwd=REPO)
def checkoutWorktree(worktreePath, ref):
subprocess.call(["git", "checkout", "-f", ref], cwd=worktreePath)
def cleanWorktree(worktreePath):
subprocess.call(["git", "clean", "-f", "-d"], cwd=worktreePath)
def updateAppInWorktree(worktreePath):
subprocess.call([worktreePath + "/scripts/update.sh"], cwd=worktreePath)
def updateAppVersionInWorktree(worktreePath):
subprocess.call([worktreePath + "/scripts/update-version.sh"], cwd=worktreePath)
worktrees = getDataForWorktrees()
updateRepoFromRemote()
print("Repo is updated from origin")
print("----------------------------------------------")
print("----------------------------------------------")
developmentWorktree = findWorktree(WORKTREE_DEVELOPMENT)
developmentWorktree.newRevision = getRevisionForRef(developmentWorktree.branch)
developmentWorktree.newVersion = getVersion(developmentWorktree.revision)
print("DEVELOPMENT (" + developmentWorktree.path + ") is on branch " + developmentWorktree.branch)
print(developmentWorktree.revision + " = " + developmentWorktree.branch + " (" + developmentWorktree.version + ")")
print(developmentWorktree.newRevision + " = origin/" + developmentWorktree.branch + " (" + developmentWorktree.newVersion + ")")
if developmentWorktree.revision != developmentWorktree.newRevision:
print("-> DEVELOPMENT (" + developmentWorktree.path + ") will be UPDATED")
print("----------------------------------------------")
checkoutWorktree(developmentWorktree.path, developmentWorktree.branch)
cleanWorktree(developmentWorktree.path)
print(developmentWorktree.path + " is checked out to " + developmentWorktree.branch + " and cleaned")
updateAppInWorktree(developmentWorktree.path)
updateAppVersionInWorktree(developmentWorktree.path)
print("RVR is updated in " + developmentWorktree.path)
elif developmentWorktree.version != developmentWorktree.newVersion:
print("-> DEVELOPMENT " + developmentWorktree.path + "'s version info will be UPDATED")
updateAppVersionInWorktree(developmentWorktree.path)
print("RVR version is updated in " + developmentWorktree.path)
else:
print("-> DEVELOPMENT (" + developmentWorktree.path + ") WON'T be updated")
print("----------------------------------------------")
print("----------------------------------------------")
productionWorktree = findWorktree(WORKTREE_PRODUCTION)
productionWorktree.newVersion = getLatestReleaseTag()
productionWorktree.newRevision = getRevisionForRef(productionWorktree.newVersion)
print("PRODUCTION (" + productionWorktree.path + ")")
print(productionWorktree.revision + " = " + productionWorktree.version)
print(productionWorktree.newRevision + " = " + productionWorktree.newVersion)
if productionWorktree.revision != productionWorktree.newRevision:
print("-> PRODUCTION (" + productionWorktree.path + ") will be UPDATED")
checkoutWorktree(productionWorktree.path, productionWorktree.newRevision)
cleanWorktree(productionWorktree.path)
print(productionWorktree.path + " is checked out to " + productionWorktree.newRevision + " and cleaned")
updateAppInWorktree(productionWorktree.path)
updateAppVersionInWorktree(productionWorktree.path)
print("RVR is updated in " + productionWorktree.path)
else:
print("-> PRODUCTION (" + productionWorktree.path + ") WON'T be updated")
print("----------------------------------------------")
print("----------------------------------------------")

View File

@ -7,7 +7,7 @@ if [[ "${BRANCH_NAME}" =~ $BRANCH_PATTERN ]]; then
TICKET_ID=$(echo $BRANCH_NAME | sed -E "s@$BRANCH_PATTERN@\\2@") TICKET_ID=$(echo $BRANCH_NAME | sed -E "s@$BRANCH_PATTERN@\\2@")
COMMIT_MESSAGE=$(head -n 1 $1) COMMIT_MESSAGE=$(head -n 1 $1)
COMMIT_MESSAGE_REGEX="^$TICKET_ID .*" COMMIT_MESSAGE_REGEX="^(fixup! )?$TICKET_ID .*"
if [[ ! "${COMMIT_MESSAGE}" =~ $COMMIT_MESSAGE_REGEX ]]; then if [[ ! "${COMMIT_MESSAGE}" =~ $COMMIT_MESSAGE_REGEX ]]; then
sed -i.bak -e "1s/^/$TICKET_ID /" $1 sed -i.bak -e "1s/^/$TICKET_ID /" $1

View File

@ -1,32 +0,0 @@
#!/bin/bash
ROOT_DIR=$(dirname $(readlink -f "$0"))/..
. ${ROOT_DIR}/.env
if [ -f ${ROOT_DIR}/installed ]; then
echo "RVR is already installed! To force reinstall, delete file 'installed' from the root directory!"
exit 1
fi
echo "Installing Yarn packages..."
(cd ${ROOT_DIR}/public/static && yarn install)
echo "Installing RVR DB..."
mysql --host=${DB_HOST} --user=${DB_USER} --password=${DB_PASSWORD} ${DB_NAME} < ${ROOT_DIR}/database/rvr.sql
echo "Migrating DB..."
(cd ${ROOT_DIR} && ./rvr db:migrate)
if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then
echo "Minifying JS, CSS and SVG files..."
${ROOT_DIR}/scripts/minify.sh
echo "Linking view files..."
(cd ${ROOT_DIR} && ./rvr view:link)
else
echo "Creating the first user..."
(cd ${ROOT_DIR} && ./rvr user:add rvr@rvr.dev 123456 admin)
fi
touch ${ROOT_DIR}/installed

View File

@ -1,11 +0,0 @@
#!/bin/bash
ROOT_DIR=$(dirname $(readlink -f "$0"))/..
. ${ROOT_DIR}/.env
find ${ROOT_DIR}/public/static/js -type f -iname '*.js' -exec uglifyjs {} -c -m -o {} \;
find ${ROOT_DIR}/public/static/css -type f -iname '*.css' -exec cleancss {} -o {} \;
find ${ROOT_DIR}/public/static/img -type f -iname '*.svg' -exec svgo {} -o {} \;

View File

@ -1,17 +0,0 @@
#!/bin/bash
ROOT_DIR=$(dirname $(readlink -f "$0"))/..
. ${ROOT_DIR}/.env
cd ${ROOT_DIR}
echo "Updating version info..."
VERSION=$(git describe --tags --always --match "Release_*" HEAD)
REVISION=$(git rev-parse --short HEAD)
REVISION_DATE=$(git show -s --format=%aI HEAD)
sed -i -E "s/const VERSION = '(.*)';/const VERSION = '${VERSION}';/" app.php
sed -i -E "s/const REVISION = '(.*)';/const REVISION = '${REVISION}';/" app.php
sed -i -E "s/const REVISION_DATE = '(.*)';/const REVISION_DATE = '${REVISION_DATE}';/" app.php

View File

@ -1,26 +0,0 @@
#!/bin/bash
ROOT_DIR=$(dirname $(readlink -f "$0"))/..
. ${ROOT_DIR}/.env
echo "Installing Composer packages..."
if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then
(cd ${ROOT_DIR} && composer install --no-dev)
else
(cd ${ROOT_DIR} && composer install --dev)
fi
echo "Installing Yarn packages..."
(cd ${ROOT_DIR}/public/static && yarn install)
echo "Migrating DB..."
(cd ${ROOT_DIR} && ./rvr db:migrate)
if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then
echo "Minifying JS, CSS and SVG files..."
${ROOT_DIR}/scripts/minify.sh
echo "Linking view files..."
(cd ${ROOT_DIR} && ./rvr view:link)
fi

View File

@ -1,7 +1,6 @@
<?php namespace RVR\Cli; <?php namespace RVR\Cli;
use DateTime; use DateTime;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\PersistentData\Model\OAuthClient; use RVR\PersistentData\Model\OAuthClient;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@ -33,8 +32,7 @@ class AddOAuthClientCommand extends Command
} }
try { try {
$pdm = new PersistentDataManager(); \Container::$persistentDataManager->saveToDb($oAuthClient);
$pdm->saveToDb($oAuthClient);
} catch (\Exception $e) { } catch (\Exception $e) {
$output->writeln('<error>Adding OAuth client failed!</error>'); $output->writeln('<error>Adding OAuth client failed!</error>');
$output->writeln(''); $output->writeln('');

View File

@ -1,6 +1,5 @@
<?php namespace RVR\Cli; <?php namespace RVR\Cli;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\Repository\OAuthClientRepository; use RVR\Repository\OAuthClientRepository;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@ -32,8 +31,7 @@ class AddOAuthRedirectUriCommand extends Command
$oAuthClient->setRedirectUrisArray($redirectUris); $oAuthClient->setRedirectUrisArray($redirectUris);
try { try {
$pdm = new PersistentDataManager(); \Container::$persistentDataManager->saveToDb($oAuthClient);
$pdm->saveToDb($oAuthClient);
} catch (\Exception $e) { } catch (\Exception $e) {
$output->writeln('<error>Adding redirect URI failed!</error>'); $output->writeln('<error>Adding redirect URI failed!</error>');
$output->writeln(''); $output->writeln('');

View File

@ -1,7 +1,6 @@
<?php namespace RVR\Cli; <?php namespace RVR\Cli;
use DateTime; use DateTime;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@ -36,8 +35,7 @@ class AddUserCommand extends Command
} }
try { try {
$pdm = new PersistentDataManager(); \Container::$persistentDataManager->saveToDb($user);
$pdm->saveToDb($user);
} catch (\Exception $e) { } catch (\Exception $e) {
$output->writeln('<error>Adding user failed!</error>'); $output->writeln('<error>Adding user failed!</error>');
$output->writeln(''); $output->writeln('');

View File

@ -4,24 +4,28 @@ use DateTime;
use SokoWeb\Database\Query\Modify; use SokoWeb\Database\Query\Modify;
use SokoWeb\Database\Query\Select; use SokoWeb\Database\Query\Select;
use SokoWeb\Interfaces\Database\IResultSet; use SokoWeb\Interfaces\Database\IResultSet;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\Repository\UserPasswordResetterRepository; use RVR\Repository\UserPasswordResetterRepository;
use RVR\Repository\OAuthTokenRepository;
use RVR\Repository\OAuthSessionRepository;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class MaintainDatabaseCommand extends Command class MaintainDatabaseCommand extends Command
{ {
private PersistentDataManager $pdm;
private UserPasswordResetterRepository $userPasswordResetterRepository; private UserPasswordResetterRepository $userPasswordResetterRepository;
private OAuthTokenRepository $oauthTokenRepository;
private OAuthSessionRepository $oauthSessionRepository;
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
$this->pdm = new PersistentDataManager();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository(); $this->userPasswordResetterRepository = new UserPasswordResetterRepository();
$this->oauthTokenRepository = new OAuthTokenRepository();
$this->oauthSessionRepository = new OAuthSessionRepository();
} }
public function configure(): void public function configure(): void
@ -35,6 +39,8 @@ class MaintainDatabaseCommand extends Command
try { try {
$this->deleteExpiredPasswordResetters(); $this->deleteExpiredPasswordResetters();
$this->deleteExpiredSessions(); $this->deleteExpiredSessions();
$this->deleteExpiredOauthTokens();
$this->deleteExpiredOauthSessions();
} catch (\Exception $e) { } catch (\Exception $e) {
$output->writeln('<error>Maintenance failed!</error>'); $output->writeln('<error>Maintenance failed!</error>');
$output->writeln(''); $output->writeln('');
@ -54,7 +60,7 @@ class MaintainDatabaseCommand extends Command
private function deleteExpiredPasswordResetters(): void private function deleteExpiredPasswordResetters(): void
{ {
foreach ($this->userPasswordResetterRepository->getAllExpired() as $passwordResetter) { foreach ($this->userPasswordResetterRepository->getAllExpired() as $passwordResetter) {
$this->pdm->deleteFromDb($passwordResetter); \Container::$persistentDataManager->deleteFromDb($passwordResetter);
} }
} }
@ -63,7 +69,7 @@ class MaintainDatabaseCommand extends Command
//TODO: model may be used for sessions too //TODO: model may be used for sessions too
$select = new Select(\Container::$dbConnection, 'sessions'); $select = new Select(\Container::$dbConnection, 'sessions');
$select->columns(['id']); $select->columns(['id']);
$select->where('updated', '<', (new DateTime('-7 days'))->format('Y-m-d H:i:s')); $select->where('updated', '<', (new DateTime('-1 days'))->format('Y-m-d H:i:s'));
$result = $select->execute(); $result = $select->execute();
@ -73,4 +79,21 @@ class MaintainDatabaseCommand extends Command
$modify->delete(); $modify->delete();
} }
} }
private function deleteExpiredOauthTokens(): void
{
foreach ($this->oauthTokenRepository->getAllExpired() as $oauthToken) {
\Container::$persistentDataManager->deleteFromDb($oauthToken);
}
}
private function deleteExpiredOauthSessions(): void
{
foreach ($this->oauthSessionRepository->getAllExpired() as $oauthSession) {
if ($this->oauthTokenRepository->countAllBySession($oauthSession) > 0) {
continue;
}
\Container::$persistentDataManager->deleteFromDb($oauthSession);
}
}
} }

View File

@ -19,6 +19,8 @@ class MigrateDatabaseCommand extends Command
{ {
$db = \Container::$dbConnection; $db = \Container::$dbConnection;
$this->createBaseDb();
$db->startTransaction(); $db->startTransaction();
$success = []; $success = [];
@ -62,10 +64,8 @@ class MigrateDatabaseCommand extends Command
return 0; return 0;
} }
private function readDir(string $type): array private function createBaseDb()
{ {
$done = [];
$migrationTableExists = \Container::$dbConnection->query('SELECT count(*) $migrationTableExists = \Container::$dbConnection->query('SELECT count(*)
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = \'' . $_ENV['DB_NAME'] . '\' WHERE table_schema = \'' . $_ENV['DB_NAME'] . '\'
@ -73,16 +73,25 @@ class MigrateDatabaseCommand extends Command
->fetch(IResultSet::FETCH_NUM)[0]; ->fetch(IResultSet::FETCH_NUM)[0];
if ($migrationTableExists != 0) { if ($migrationTableExists != 0) {
$select = new Select(\Container::$dbConnection, 'migrations'); return;
$select->columns(['migration']); }
$select->where('type', '=', $type);
$select->orderBy('migration');
$result = $select->execute(); \Container::$dbConnection->multiQuery(file_get_contents(ROOT . '/database/rvr.sql'));
}
while ($migration = $result->fetch(IResultSet::FETCH_ASSOC)) { private function readDir(string $type): array
$done[] = $migration['migration']; {
} $done = [];
$select = new Select(\Container::$dbConnection, 'migrations');
$select->columns(['migration']);
$select->where('type', '=', $type);
$select->orderBy('migration');
$result = $select->execute();
while ($migration = $result->fetch(IResultSet::FETCH_ASSOC)) {
$done[] = $migration['migration'];
} }
$path = ROOT . '/database/migrations/' . $type; $path = ROOT . '/database/migrations/' . $type;

View File

@ -1,6 +1,5 @@
<?php namespace RVR\Cli; <?php namespace RVR\Cli;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\Repository\OAuthClientRepository; use RVR\Repository\OAuthClientRepository;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@ -32,8 +31,7 @@ class RemoveOAuthRedirectUriCommand extends Command
$oAuthClient->setRedirectUrisArray($redirectUris); $oAuthClient->setRedirectUrisArray($redirectUris);
try { try {
$pdm = new PersistentDataManager(); \Container::$persistentDataManager->saveToDb($oAuthClient);
$pdm->saveToDb($oAuthClient);
} catch (\Exception $e) { } catch (\Exception $e) {
$output->writeln('<error>Removing redirect URI failed!</error>'); $output->writeln('<error>Removing redirect URI failed!</error>');
$output->writeln(''); $output->writeln('');

View File

@ -1,38 +1,49 @@
<?php namespace RVR\Controller; <?php namespace RVR\Controller;
use DateTime; use DateTime;
use RVR\Finance\BalanceCalculator;
use RVR\PersistentData\Model\Community; use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\CommunityMember; use RVR\PersistentData\Model\CommunityMember;
use RVR\PersistentData\Model\Currency;
use RVR\PersistentData\Model\CurrencyExchangeRate;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use RVR\Repository\CommunityRepository; use RVR\Repository\CommunityRepository;
use RVR\Repository\CommunityMemberRepository; use RVR\Repository\CommunityMemberRepository;
use RVR\Repository\CurrencyExchangeRateRepository;
use RVR\Repository\CurrencyRepository;
use RVR\Repository\EventRepository;
use RVR\Repository\TransactionRepository;
use RVR\Repository\UserRepository; use RVR\Repository\UserRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired; use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\PersistentData\PersistentDataManager;
use SokoWeb\Response\HtmlContent; use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\JsonContent; use SokoWeb\Response\JsonContent;
class CommunityController implements IAuthenticationRequired class CommunityController implements IAuthenticationRequired
{ {
private IRequest $request;
private PersistentDataManager $pdm;
private UserRepository $userRepository; private UserRepository $userRepository;
private CommunityRepository $communityRepository; private CommunityRepository $communityRepository;
private CommunityMemberRepository $communityMemberRepository; private CommunityMemberRepository $communityMemberRepository;
public function __construct(IRequest $request) private CurrencyRepository $currencyRepository;
private CurrencyExchangeRateRepository $currencyExchangeRatesRepository;
private TransactionRepository $transactionRepository;
private EventRepository $eventRepository;
public function __construct()
{ {
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->userRepository = new UserRepository(); $this->userRepository = new UserRepository();
$this->communityRepository = new CommunityRepository(); $this->communityRepository = new CommunityRepository();
$this->communityMemberRepository = new CommunityMemberRepository(); $this->communityMemberRepository = new CommunityMemberRepository();
$this->currencyRepository = new CurrencyRepository();
$this->currencyExchangeRatesRepository = new CurrencyExchangeRateRepository();
$this->transactionRepository = new TransactionRepository();
$this->eventRepository = new EventRepository();
} }
public function isAuthenticationRequired(): bool public function isAuthenticationRequired(): bool
@ -42,15 +53,41 @@ class CommunityController implements IAuthenticationRequired
public function getCommunityHome(): ?IContent public function getCommunityHome(): ?IContent
{ {
if (!$this->checkPermission($this->request->query('communityId'), false, $community, $ownCommunityMember)) { if (!$this->checkPermission(\Container::$request->query('communitySlug'), false, $community, $ownCommunityMember)) {
return null; return null;
} }
\Container::$persistentDataManager->loadRelationsFromDb($community, false, ['main_currency']);
/**
* @var User $user
*/
$user = \Container::$request->user();
$balanceCalculator = new BalanceCalculator($community, $user);
$balance = $balanceCalculator->calculate();
return new HtmlContent('communities/community', [ return new HtmlContent('communities/community', [
'community' => $community,
'upcomingAndRecentEvents' => iterator_to_array($this->eventRepository->getUpcomingAndRecentByCommunity($community, new DateTime(), 30, 3)),
'debtItems' => $balance['debtItems'],
'debtBalance' => $balance['debtBalance'],
'outstandingItems' => $balance['outstandingItems'],
'outstandingBalance' => $balance['outstandingBalance'],
'balance' => $balance['absoluteBalance'],
'editPermission' => $ownCommunityMember->getOwner()
]);
}
public function getCommunitySettings(): ?IContent
{
if (!$this->checkPermission(\Container::$request->query('communitySlug'), false, $community, $ownCommunityMember)) {
return null;
}
return new HtmlContent('communities/community_settings', [
'community' => $community, 'community' => $community,
'members' => $this->getMembers($community), 'members' => $this->getMembers($community),
'currencyNames' => [], 'currencies' => $this->getCurrencies($community),
'upcomingEvents' => [],
'editPermission' => $ownCommunityMember->getOwner() 'editPermission' => $ownCommunityMember->getOwner()
]); ]);
} }
@ -62,7 +99,7 @@ class CommunityController implements IAuthenticationRequired
public function getCommunityEdit(): ?IContent public function getCommunityEdit(): ?IContent
{ {
if (!$this->checkPermission($this->request->query('communityId'), true, $community, $ownCommunityMember)) { if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null; return null;
} }
@ -71,9 +108,103 @@ class CommunityController implements IAuthenticationRequired
]); ]);
} }
public function saveCommunity(): ?IContent
{
$name = \Container::$request->post('name');
if (strlen($name) === 0) {
return new JsonContent([
'error' => ['errorText' => 'Please fill all required fields!']
]);
}
$communitySlug = \Container::$request->query('communitySlug');
if ($communitySlug){
if (!$this->checkPermission($communitySlug, true, $community, $ownCommunityMember)) {
return null;
}
} else {
$mainCurrencyCode = \Container::$request->post('main_currency_code');
$mainCurrencyRoundDigits = \Container::$request->post('main_currency_round_digits');
if (strlen($mainCurrencyCode) === 0 || strlen($mainCurrencyCode) > 3 || $mainCurrencyRoundDigits < 0 || $mainCurrencyRoundDigits > 9) {
return new JsonContent([
'error' => ['errorText' => 'Please fill all required fields!']
]);
}
$community = new Community();
$community->setCreatedDate(new DateTime());
}
$community->setName($name);
\Container::$persistentDataManager->saveToDb($community);
if (!$communitySlug) {
/**
* @var User $user
*/
$user = \Container::$request->user();
$communityMember = new CommunityMember();
$communityMember->setCommunity($community);
$communityMember->setUser($user);
$communityMember->setOwner(true);
\Container::$persistentDataManager->saveToDb($communityMember);
$mainCurrency = new Currency();
$mainCurrency->setCommunity($community);
$mainCurrency->setCode($mainCurrencyCode);
$mainCurrency->setRoundDigits($mainCurrencyRoundDigits);
\Container::$persistentDataManager->saveToDb($mainCurrency);
$community->setMainCurrency($mainCurrency);
\Container::$persistentDataManager->saveToDb($community);
}
return new JsonContent([
'redirect' => ['target' => \Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()])]
]);
}
public function deleteCommunity(): ?IContent
{
if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null;
}
if ($this->transactionRepository->countAllByCommunity($community) > 0) {
return new JsonContent([
'error' => ['errorText' => 'There are transactions for this community!']
]);
}
if ($this->eventRepository->countAllByCommunity($community) > 0) {
return new JsonContent([
'error' => ['errorText' => 'There are events for this community!']
]);
}
foreach ($this->communityMemberRepository->getAllByCommunity($community) as $communityMember) {
\Container::$persistentDataManager->deleteFromDb($communityMember);
}
$community->setMainCurrencyId(null);
\Container::$persistentDataManager->saveToDb($community);
foreach ($this->currencyRepository->getAllByCommunity($community) as $currency) {
foreach ($this->currencyExchangeRatesRepository->getAllByCurrency($currency) as $currencyExchangeRate) {
\Container::$persistentDataManager->deleteFromDb($currencyExchangeRate);
}
\Container::$persistentDataManager->deleteFromDb($currency);
}
\Container::$persistentDataManager->deleteFromDb($community);
return new JsonContent(['success' => true]);
}
public function getMembersEdit(): ?IContent public function getMembersEdit(): ?IContent
{ {
if (!$this->checkPermission($this->request->query('communityId'), true, $community, $ownCommunityMember)) { if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null; return null;
} }
@ -83,120 +214,243 @@ class CommunityController implements IAuthenticationRequired
]); ]);
} }
private function getMembers(Community $community): array public function saveMember(): ?IContent
{ {
$members = iterator_to_array($this->communityMemberRepository->getAllByCommunity($community, true)); if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
usort($members, function($a, $b) {
return strnatcmp($a->getUser()->getDisplayName(), $b->getUser()->getDisplayName());
});
return $members;
}
public function newMember(): ?IContent
{
if (!$this->checkPermission($this->request->query('communityId'), true, $community, $ownCommunityMember)) {
return null; return null;
} }
$user = $this->userRepository->getById($this->request->post('user_id')); $communityMemberId = \Container::$request->query('community_member_id');
if ($communityMemberId) {
$communityMember = $this->communityMemberRepository->getById($communityMemberId);
if ($communityMember->getUserId() === $ownCommunityMember->getUserId()) {
return new JsonContent([
'error' => ['errorText' => 'Own user cannot be edited.']
]);
}
} else {
if ($this->transactionRepository->isAnyCommon()) {
return new JsonContent([
'error' => ['errorText' => 'There are transactions with common payee!']
]);
}
$communityMember = new CommunityMember(); $user = $this->userRepository->getById(\Container::$request->post('user_id'));
$communityMember->setCommunity($community); if ($this->communityMemberRepository->getByCommunityAndUser($community, $user) !== null) {
$communityMember->setUser($user); return new JsonContent([
$this->pdm->saveToDb($communityMember); 'error' => ['errorText' => 'This user is already a member of this community.']
]);
}
return new JsonContent(['success' => true]); $communityMember = new CommunityMember();
} $communityMember->setCommunity($community);
$communityMember->setUser($user);
public function editMember(): ?IContent
{
if (!$this->checkPermission($this->request->query('communityId'), true, $community, $ownCommunityMember)) {
return null;
} }
$communityMember = $this->communityMemberRepository->getById($this->request->post('community_member_id')); $communityMember->setOwner((bool)\Container::$request->post('owner'));
if ($communityMember->getUserId() === $this->request->user()->getUniqueId()) { \Container::$persistentDataManager->saveToDb($communityMember);
return new JsonContent([
'error' => ['errorText' => 'Own user cannot be edited.']
]);
}
$communityMember->setOwner($this->request->post('owner'));
$this->pdm->saveToDb($communityMember);
return new JsonContent(['success' => true]); return new JsonContent(['success' => true]);
} }
public function deleteMember(): ?IContent public function deleteMember(): ?IContent
{ {
if (!$this->checkPermission($this->request->query('communityId'), true, $community, $ownCommunityMember)) { if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null; return null;
} }
$communityMember = $this->communityMemberRepository->getById($this->request->post('community_member_id')); $communityMember = $this->communityMemberRepository->getById(\Container::$request->query('community_member_id'));
if ($communityMember->getUserId() === $this->request->user()->getUniqueId()) { if ($communityMember->getUserId() === \Container::$request->user()->getUniqueId()) {
return new JsonContent([ return new JsonContent([
'error' => ['errorText' => 'Own user cannot be deleted.'] 'error' => ['errorText' => 'Own user cannot be deleted.']
]); ]);
} }
$this->pdm->deleteFromDb($communityMember); \Container::$persistentDataManager->loadRelationsFromDb($communityMember, false, ['user']);
if ($this->transactionRepository->isAnyForUser($communityMember->getUser())) {
return new JsonContent([
'error' => ['errorText' => 'There are transactions where the member is payer or payee!']
]);
}
\Container::$persistentDataManager->deleteFromDb($communityMember);
return new JsonContent(['success' => true]); return new JsonContent(['success' => true]);
} }
public function saveCommunity(): ?IContent public function getCurrenciesEdit(): ?IContent
{ {
$communityId = $this->request->query('communityId'); if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
if ($communityId){ return null;
if (!$this->checkPermission($communityId, true, $community, $ownCommunityMember)) {
return null;
}
} else {
$community = new Community();
} }
$name = $this->request->post('name'); return new HtmlContent('communities/community_currencies', [
$currency = $this->request->post('currency'); 'community' => $community,
if (strlen($name) === 0 || strlen($currency) === 0 || strlen($currency) > 3) { 'currencies' => $this->getCurrencies($community)
]);
}
public function saveCurrency(): ?IContent
{
if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null;
}
$code = \Container::$request->post('code');
$roundDigits = (int)\Container::$request->post('round_digits');
if (strlen($code) === 0 || strlen($code) > 3 || $roundDigits < 0 || $roundDigits > 9) {
return new JsonContent([ return new JsonContent([
'error' => ['errorText' => 'Please fill all required fields!'] 'error' => ['errorText' => 'Please fill all required fields!']
]); ]);
} }
$community->setName($name); $currencyId = \Container::$request->query('currency_id');
$community->setCurrency($currency); if ($currencyId){
if (!$communityId) { $currency = $this->currencyRepository->getById($currencyId);
$community->setCreatedDate(new DateTime()); } else {
} $currency = new Currency();
$this->pdm->saveToDb($community); $currency->setCommunity($community);
if (!$communityId) {
/**
* @var User $user
*/
$user = $this->request->user();
$communityMember = new CommunityMember();
$communityMember->setCommunity($community);
$communityMember->setUser($user);
$communityMember->setOwner(true);
$this->pdm->saveToDb($communityMember);
} }
return new JsonContent([ $existingCurrency = $this->currencyRepository->getByCommunityAndCurrencyCode($community, $code);
'redirect' => ['target' => '/' . \Container::$routeCollection->getRoute('community')->generateLink(['communityId' => $community->getId()])] if ($existingCurrency !== null && $currency->getId() !== $existingCurrency->getId()) {
return new JsonContent([
'error' => ['errorText' => 'A currency with the same code exists for this community.']
]);
}
$currency->setCode($code);
$currency->setRoundDigits($roundDigits);
\Container::$persistentDataManager->saveToDb($currency);
return new JsonContent(['success' => true]);
}
public function deleteCurrency(): ?IContent
{
if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null;
}
$currency = $this->currencyRepository->getById(\Container::$request->query('currency_id'));
if ($currency->getId() === $community->getMainCurrencyId()) {
return null;
}
if ($this->transactionRepository->isAnyForCurrency($currency)) {
return new JsonContent([
'error' => ['errorText' => 'There are transactions with this currency!']
]);
}
foreach ($this->currencyExchangeRatesRepository->getAllByCurrency($currency) as $currencyExchangeRate) {
\Container::$persistentDataManager->deleteFromDb($currencyExchangeRate);
}
\Container::$persistentDataManager->deleteFromDb($currency);
return new JsonContent(['success' => true]);
}
public function getCurrencyExchangeRates(): ?IContent
{
if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null;
}
$currency = $this->currencyRepository->getByCommunityAndCurrencyCode($community, \Container::$request->query('code'));
if ($currency === null || $currency->getId() === $community->getMainCurrencyId()) {
return null;
}
$currencyExchangeRates = $this->currencyExchangeRatesRepository->getAllByCurrency($currency);
return new HtmlContent('communities/currency_exchange_rates', [
'community' => $community,
'currency' => $currency,
'currencyExchangeRates' => $currencyExchangeRates,
'editPermission' => $ownCommunityMember->getOwner()
]); ]);
} }
public function saveCurrencyExchangeRate(): ?IContent
{
if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null;
}
$currency = $this->currencyRepository->getByCommunityAndCurrencyCode($community, \Container::$request->query('code'));
if ($currency === null) {
return null;
}
$exchangeRate = (float)\Container::$request->post('exchange_rate');
if ($exchangeRate < 0) {
return new JsonContent([
'error' => ['errorText' => 'Please fill all required fields!']
]);
}
$currencyExchangeRateId = \Container::$request->query('currency_exchange_rate_id');
if ($currencyExchangeRateId){
$currencyExchangeRate = $this->currencyExchangeRatesRepository->getById($currencyExchangeRateId);
} else {
$currencyExchangeRate = new CurrencyExchangeRate();
$currencyExchangeRate->setCurrency($currency);
}
$currencyExchangeRate->setExchangeRate($exchangeRate);
$currencyExchangeRate->setValidFromDate(new DateTime(\Container::$request->post('valid_from')));
\Container::$persistentDataManager->saveToDb($currencyExchangeRate);
return new JsonContent(['success' => true]);
}
public function deleteCurrencyExchangeRate(): ?IContent
{
if (!$this->checkPermission(\Container::$request->query('communitySlug'), true, $community, $ownCommunityMember)) {
return null;
}
$currency = $this->currencyRepository->getByCommunityAndCurrencyCode($community, \Container::$request->query('code'));
if ($currency === null) {
return null;
}
$currencyExchangeRate = $this->currencyExchangeRatesRepository->getById(\Container::$request->query('currency_exchange_rate_id'));
\Container::$persistentDataManager->deleteFromDb($currencyExchangeRate);
return new JsonContent(['success' => true]);
}
private function getMembers(Community $community): array
{
$members = iterator_to_array($this->communityMemberRepository->getAllByCommunity($community, true, ['user']));
usort($members, function($a, $b) {
return strnatcmp($a->getUser()->getDisplayName(), $b->getUser()->getDisplayName());
});
return $members;
}
private function getCurrencies(Community $community): array
{
$currencies = iterator_to_array($this->currencyRepository->getAllByCommunity($community));
usort($currencies, function($a, $b) {
return strnatcmp($a->getCode(), $b->getCode());
});
usort($currencies, function($a, $b) use ($community) {
return (int)($b->getId() === $community->getMainCurrencyId()) - (int)($a->getId() === $community->getMainCurrencyId());
});
return $currencies;
}
private function checkPermission( private function checkPermission(
int $communityId, string $communitySlug,
bool $needToBeOwner, bool $needToBeOwner,
?Community &$community, ?Community &$community,
?CommunityMember &$ownCommunityMember): bool ?CommunityMember &$ownCommunityMember): bool
{ {
$community = $this->communityRepository->getById($communityId); $community = $this->communityRepository->getBySlug($communitySlug);
if ($community === null) { if ($community === null) {
return false; return false;
} }
@ -204,7 +458,7 @@ class CommunityController implements IAuthenticationRequired
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = \Container::$request->user();
$ownCommunityMember = $this->communityMemberRepository->getByCommunityAndUser($community, $user); $ownCommunityMember = $this->communityMemberRepository->getByCommunityAndUser($community, $user);
if ($ownCommunityMember === null || ($needToBeOwner && !$ownCommunityMember->getOwner())) { if ($ownCommunityMember === null || ($needToBeOwner && !$ownCommunityMember->getOwner())) {

View File

@ -0,0 +1,193 @@
<?php namespace RVR\Controller;
use Container;
use DateTime;
use RVR\Finance\BalanceCalculator;
use RVR\Finance\ExchangeRateCalculator;
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\CommunityMember;
use RVR\PersistentData\Model\Event;
use RVR\PersistentData\Model\User;
use RVR\Repository\CommunityMemberRepository;
use RVR\Repository\CommunityRepository;
use RVR\Repository\EventRepository;
use RVR\Repository\TransactionRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Authorization\ISecured;
use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\JsonContent;
class EventController implements IAuthenticationRequired, ISecured
{
private CommunityRepository $communityRepository;
private CommunityMemberRepository $communityMemberRepository;
private EventRepository $eventRepository;
private TransactionRepository $transactionRepository;
private ?Community $community;
private ?CommunityMember $ownCommunityMember;
public function __construct()
{
$this->communityRepository = new CommunityRepository();
$this->communityMemberRepository = new CommunityMemberRepository();
$this->eventRepository = new EventRepository();
$this->transactionRepository = new TransactionRepository();
}
public function isAuthenticationRequired(): bool
{
return true;
}
public function authorize(): bool
{
$communitySlug = \Container::$request->query('communitySlug');
$this->community = $this->communityRepository->getBySlug($communitySlug);
if ($this->community === null) {
return false;
}
/**
* @var User $user
*/
$user = \Container::$request->user();
$this->ownCommunityMember = $this->communityMemberRepository->getByCommunityAndUser($this->community, $user);
if ($this->ownCommunityMember === null) {
return false;
}
return true;
}
public function getEvents(): IContent
{
$itemsPerPage = 10;
$numberOfEvents = $this->eventRepository->countAllByCommunity($this->community);
$currentPage = Container::$request->query('page') ?: 1;
$events = $this->eventRepository->getPagedByCommunity(
$this->community,
$currentPage,
$itemsPerPage
);
return new HtmlContent('events/events', [
'community' => $this->community,
'pages' => ceil($numberOfEvents / $itemsPerPage),
'currentPage' => $currentPage,
'numberOfEvents' => $numberOfEvents,
'events' => $events
]);
}
public function searchEvent(): IContent
{
$events = iterator_to_array($this->eventRepository->searchByTitle($this->community, Container::$request->query('q')));
$results = [];
foreach ($events as $event) {
$results[] = ['value' => $event->getId(), 'label' => $event->getTitle()];
}
return new JsonContent([
'results' => $results
]);
}
public function getEvent(): ?IContent
{
$event = $this->eventRepository->getBySlug(Container::$request->query('eventSlug'));
if (!$event) {
return null;
}
Container::$persistentDataManager->loadRelationsFromDb($this->community, true, ['main_currency']);
/**
* @var User $user
*/
$user = \Container::$request->user();
$balanceCalculator = new BalanceCalculator($this->community, $user, $event);
$balance = $balanceCalculator->calculate();
return new HtmlContent('events/event', [
'community' => $this->community,
'event' => $event,
'totalCost' => $this->sumTransactions($event),
'debtItems' => $balance['debtItems'],
'debtBalance' => $balance['debtBalance'],
'outstandingItems' => $balance['outstandingItems'],
'outstandingBalance' => $balance['outstandingBalance'],
'balance' => $balance['absoluteBalance'],
]);
}
public function getEventEdit(): ?IContent
{
$eventSlug = Container::$request->query('eventSlug');
if ($eventSlug) {
$event = $this->eventRepository->getBySlug($eventSlug);
if ($event === null) {
return null;
}
} else {
$event = null;
}
return new HtmlContent('events/event_edit', [
'community' => $this->community,
'event' => $event
]);
}
public function saveEvent(): ?IContent
{
$eventSlug = Container::$request->query('eventSlug');
if ($eventSlug) {
$event = $this->eventRepository->getBySlug($eventSlug);
} else {
$event = new Event();
$event->setCommunity($this->community);
}
$event->setTitle(Container::$request->post('title'));
$event->setDescription(Container::$request->post('description'));
$event->setStartDate(new DateTime(Container::$request->post('start')));
$event->setEndDate(new DateTime(Container::$request->post('end')));
Container::$persistentDataManager->saveToDb($event);
return new JsonContent([
'redirect' => ['target' => \Container::$routeCollection->getRoute('community.event')->generateLink(['communitySlug' => $this->community->getSlug(), 'eventSlug' => $event->getSlug()])]
]);
}
public function deleteEvent(): IContent
{
$event = $this->eventRepository->getBySlug(Container::$request->query('eventSlug'));
foreach ($this->transactionRepository->getAllByEvent($event) as $transaction) {
$transaction->setEventId(null);
Container::$persistentDataManager->saveToDb($transaction);
}
Container::$persistentDataManager->deleteFromDb($event);
return new JsonContent(['success' => true]);
}
private function sumTransactions(Event $event): float
{
$exchangeRateCalculator = new ExchangeRateCalculator($this->community->getMainCurrency());
$transactions = $this->transactionRepository->getAllByEvent($event, true, ['currency']);
$sum = 0.0;
foreach ($transactions as $transaction) {
$sum += $exchangeRateCalculator->calculate($transaction->getSum(), $transaction->getCurrency(), $transaction->getTimeDate());
}
return $sum;
}
}

View File

@ -0,0 +1,70 @@
<?php namespace RVR\Controller;
use DateTime;
use Container;
use RVR\PersistentData\Model\Event;
use RVR\PersistentData\Model\User;
use RVR\Repository\EventRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\Redirect;
class EventRedirectController implements IAuthenticationRequired
{
private EventRepository $eventRepository;
public function __construct()
{
$this->eventRepository = new EventRepository();
}
public function isAuthenticationRequired(): bool
{
return true;
}
public function getEvent()
{
$currentEvent = $this->getCurrentEvent();
if ($currentEvent === null) {
return new HtmlContent('event_redirect/no_event');
}
return new Redirect(
\Container::$routeCollection->getRoute('community.event')
->generateLink([
'communitySlug' => $currentEvent->getCommunity()->getSlug(),
'eventSlug' => $currentEvent->getSlug()
]),
IRedirect::TEMPORARY
);
}
public function getEventNewTransaction()
{
$currentEvent = $this->getCurrentEvent();
if ($currentEvent === null) {
return new HtmlContent('event_redirect/no_event');
}
return new Redirect(
\Container::$routeCollection->getRoute('community.transactions.new')
->generateLink([
'communitySlug' => $currentEvent->getCommunity()->getSlug(),
'event' => $currentEvent->getSlug()
]),
IRedirect::TEMPORARY
);
}
private function getCurrentEvent(): ?Event
{
/**
* @var User $user
*/
$user = Container::$request->user();
return $this->eventRepository->getCurrentByUser($user, new DateTime(), 30, true, ['community']);
}
}

View File

@ -1,22 +1,24 @@
<?php namespace RVR\Controller; <?php namespace RVR\Controller;
use DateTime;
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use RVR\Repository\CommunityMemberRepository; use RVR\Repository\CommunityMemberRepository;
use RVR\Repository\EventRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired; use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Response\HtmlContent; use SokoWeb\Response\HtmlContent;
class HomeController implements IAuthenticationRequired class HomeController implements IAuthenticationRequired
{ {
private IRequest $request;
private CommunityMemberRepository $communityMemberRepository; private CommunityMemberRepository $communityMemberRepository;
public function __construct(IRequest $request) private EventRepository $eventRepository;
public function __construct()
{ {
$this->request = $request;
$this->communityMemberRepository = new CommunityMemberRepository(); $this->communityMemberRepository = new CommunityMemberRepository();
$this->eventRepository = new EventRepository();
} }
public function isAuthenticationRequired(): bool public function isAuthenticationRequired(): bool
@ -29,9 +31,9 @@ class HomeController implements IAuthenticationRequired
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = \Container::$request->user();
$ownCommunityMembers = $this->communityMemberRepository->getAllByUser($user, true); $ownCommunityMembers = $this->communityMemberRepository->getAllByUser($user, true, ['community']);
$communities = []; $communities = [];
foreach ($ownCommunityMembers as $ownCommunityMember) { foreach ($ownCommunityMembers as $ownCommunityMember) {
$communities[] = $ownCommunityMember->getCommunity(); $communities[] = $ownCommunityMember->getCommunity();
@ -42,7 +44,7 @@ class HomeController implements IAuthenticationRequired
return new HtmlContent('home', [ return new HtmlContent('home', [
'communities' => $communities, 'communities' => $communities,
'upcomingEvents' => [] 'upcomingAndRecentEvents' => iterator_to_array($this->eventRepository->getUpcomingAndRecentByUser($user, new DateTime(), 30, 3, true, ['community']))
]); ]);
} }
} }

View File

@ -2,13 +2,11 @@
use DateTime; use DateTime;
use SokoWeb\Http\Request; use SokoWeb\Http\Request;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Interfaces\Response\IRedirect; use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Mailing\Mail; use SokoWeb\Mailing\Mail;
use SokoWeb\OAuth\GoogleOAuth; use SokoWeb\OAuth\GoogleOAuth;
use RVR\PersistentData\Model\UserPasswordResetter; use RVR\PersistentData\Model\UserPasswordResetter;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\Repository\UserPasswordResetterRepository; use RVR\Repository\UserPasswordResetterRepository;
use RVR\Repository\UserRepository; use RVR\Repository\UserRepository;
use SokoWeb\Response\HtmlContent; use SokoWeb\Response\HtmlContent;
@ -19,35 +17,29 @@ use SokoWeb\Util\JwtParser;
class LoginController class LoginController
{ {
private IRequest $request;
private PersistentDataManager $pdm;
private UserRepository $userRepository; private UserRepository $userRepository;
private UserPasswordResetterRepository $userPasswordResetterRepository; private UserPasswordResetterRepository $userPasswordResetterRepository;
private string $redirectUrl; private string $redirectUrl;
public function __construct(IRequest $request) public function __construct()
{ {
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->userRepository = new UserRepository(); $this->userRepository = new UserRepository();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository(); $this->userPasswordResetterRepository = new UserPasswordResetterRepository();
$this->redirectUrl = $this->request->session()->has('redirect_after_login') ? $this->redirectUrl = \Container::$request->session()->has('redirect_after_login') ?
$this->request->session()->get('redirect_after_login') : \Container::$request->session()->get('redirect_after_login') :
\Container::$routeCollection->getRoute('home')->generateLink(); \Container::$routeCollection->getRoute('home')->generateLink();
} }
public function getLoginForm() public function getLoginForm()
{ {
if ($this->request->user() !== null) { if (\Container::$request->user() !== null) {
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); return new Redirect($this->redirectUrl, IRedirect::TEMPORARY);
} }
return new HtmlContent('login/login', ['redirectUrl' => '/' . $this->redirectUrl]); return new HtmlContent('login/login', ['redirectUrl' => $this->redirectUrl]);
} }
public function getGoogleLoginRedirect(): IRedirect public function getGoogleLoginRedirect(): IRedirect
@ -55,13 +47,13 @@ class LoginController
$state = bin2hex(random_bytes(16)); $state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16)); $nonce = bin2hex(random_bytes(16));
$this->request->session()->set('oauth_state', $state); \Container::$request->session()->set('oauth_state', $state);
$this->request->session()->set('oauth_nonce', $nonce); \Container::$request->session()->set('oauth_nonce', $nonce);
$oAuth = new GoogleOAuth(new Request()); $oAuth = new GoogleOAuth(new Request());
$url = $oAuth->getDialogUrl( $url = $oAuth->getDialogUrl(
$state, $state,
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('login-google-action')->generateLink(), \Container::$request->getBase() . \Container::$routeCollection->getRoute('login.google-action')->generateLink(),
$nonce $nonce
); );
@ -70,12 +62,12 @@ class LoginController
public function getRequestPasswordResetForm() public function getRequestPasswordResetForm()
{ {
if ($this->request->user() !== null) { if (\Container::$request->user() !== null) {
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); return new Redirect($this->redirectUrl, IRedirect::TEMPORARY);
} }
return new HtmlContent('login/password_reset_request', ['email' => $this->request->query('email')]); return new HtmlContent('login/password_reset_request', ['email' => \Container::$request->query('email')]);
} }
public function getRequestPasswordResetSuccess(): IContent public function getRequestPasswordResetSuccess(): IContent
@ -85,12 +77,12 @@ class LoginController
public function getResetPasswordForm() public function getResetPasswordForm()
{ {
if ($this->request->user() !== null) { if (\Container::$request->user() !== null) {
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); return new Redirect($this->redirectUrl, IRedirect::TEMPORARY);
} }
$token = $this->request->query('token'); $token = \Container::$request->query('token');
$resetter = $this->userPasswordResetterRepository->getByToken($token); $resetter = $this->userPasswordResetterRepository->getByToken($token);
if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) { if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) {
@ -99,27 +91,27 @@ class LoginController
$user = $this->userRepository->getById($resetter->getUserId()); $user = $this->userRepository->getById($resetter->getUserId());
return new HtmlContent('login/reset_password', ['success' => true, 'token' => $token, 'email' => $user->getEmail(), 'redirectUrl' => '/' . $this->redirectUrl]); return new HtmlContent('login/reset_password', ['success' => true, 'token' => $token, 'email' => $user->getEmail(), 'redirectUrl' => $this->redirectUrl]);
} }
public function login(): IContent public function login(): IContent
{ {
if ($this->request->user() !== null) { if (\Container::$request->user() !== null) {
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new JsonContent(['success' => true]); return new JsonContent(['success' => true]);
} }
$user = $this->userRepository->getByEmailOrUsername($this->request->post('email')); $user = $this->userRepository->getByEmailOrUsername(\Container::$request->post('email'));
if ($user === null || !$user->checkPassword($this->request->post('password'))) { if ($user === null || !$user->checkPassword(\Container::$request->post('password'))) {
return new JsonContent([ return new JsonContent([
'error' => [ 'error' => [
'errorText' => 'No user found with the given email address / username or the given password is wrong. You can <a href="' . 'errorText' => 'No user found with the given email address / username or the given password is wrong. You can <a href="' .
\Container::$routeCollection->getRoute('password-requestReset')->generateLink(['email' => $this->request->post('email')]) . '" title="Request password reset">request password reset</a>!' \Container::$routeCollection->getRoute('password.requestReset')->generateLink(['email' => \Container::$request->post('email')]) . '" title="Request password reset">request password reset</a>!'
] ]
]); ]);
} }
$this->request->setUser($user); \Container::$request->setUser($user);
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new JsonContent(['success' => true]); return new JsonContent(['success' => true]);
@ -127,21 +119,21 @@ class LoginController
public function loginWithGoogle() public function loginWithGoogle()
{ {
$defaultError = 'Authentication with Google failed. Please <a href="' . \Container::$routeCollection->getRoute('login-google')->generateLink() . '" title="Login with Google">try again</a>!'; $defaultError = 'Authentication with Google failed. Please <a href="' . \Container::$routeCollection->getRoute('login.google')->generateLink() . '" title="Login with Google">try again</a>!';
if ($this->request->user() !== null) { if (\Container::$request->user() !== null) {
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); return new Redirect($this->redirectUrl, IRedirect::TEMPORARY);
} }
if ($this->request->query('state') !== $this->request->session()->get('oauth_state')) { if (\Container::$request->query('state') !== \Container::$request->session()->get('oauth_state')) {
return new HtmlContent('login/google_login_error', ['error' => $defaultError]); return new HtmlContent('login/google_login_error', ['error' => $defaultError]);
} }
$oAuth = new GoogleOAuth(new Request()); $oAuth = new GoogleOAuth(new Request());
$tokenData = $oAuth->getToken( $tokenData = $oAuth->getToken(
$this->request->query('code'), \Container::$request->query('code'),
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('login-google-action')->generateLink() \Container::$request->getBase() . \Container::$routeCollection->getRoute('login.google-action')->generateLink()
); );
if (!isset($tokenData['id_token'])) { if (!isset($tokenData['id_token'])) {
@ -151,7 +143,7 @@ class LoginController
$jwtParser = new JwtParser($tokenData['id_token']); $jwtParser = new JwtParser($tokenData['id_token']);
$idToken = $jwtParser->getPayload(); $idToken = $jwtParser->getPayload();
if ($idToken['nonce'] !== $this->request->session()->get('oauth_nonce')) { if ($idToken['nonce'] !== \Container::$request->session()->get('oauth_nonce')) {
return new HtmlContent('login/google_login_error', ['error' => $defaultError]); return new HtmlContent('login/google_login_error', ['error' => $defaultError]);
} }
@ -164,7 +156,7 @@ class LoginController
return new HtmlContent('login/google_login_error', ['error' => 'No user found for this Google account.']); return new HtmlContent('login/google_login_error', ['error' => 'No user found for this Google account.']);
} }
$this->request->setUser($user); \Container::$request->setUser($user);
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new Redirect($this->redirectUrl, IRedirect::TEMPORARY); return new Redirect($this->redirectUrl, IRedirect::TEMPORARY);
@ -172,39 +164,39 @@ class LoginController
public function logout(): IRedirect public function logout(): IRedirect
{ {
$this->request->setUser(null); \Container::$request->setUser(null);
return new Redirect(\Container::$routeCollection->getRoute('home')->generateLink(), IRedirect::TEMPORARY); return new Redirect(\Container::$routeCollection->getRoute('home')->generateLink(), IRedirect::TEMPORARY);
} }
public function requestPasswordReset(): IContent public function requestPasswordReset(): IContent
{ {
if ($this->request->user() !== null) { if (\Container::$request->user() !== null) {
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new JsonContent([ return new JsonContent([
'redirect' => [ 'redirect' => [
'target' => '/' . $this->redirectUrl 'target' => $this->redirectUrl
] ]
]); ]);
} }
if (!empty($_ENV['RECAPTCHA_SITEKEY'])) { if (!empty($_ENV['RECAPTCHA_SITEKEY'])) {
if (!$this->request->post('g-recaptcha-response')) { if (!\Container::$request->post('g-recaptcha-response')) {
return new JsonContent(['error' => ['errorText' => 'Please check "I\'m not a robot" in the reCAPTCHA box!']]); return new JsonContent(['error' => ['errorText' => 'Please check "I\'m not a robot" in the reCAPTCHA box!']]);
} }
$captchaValidator = new CaptchaValidator(); $captchaValidator = new CaptchaValidator();
$captchaResponse = $captchaValidator->validate($this->request->post('g-recaptcha-response')); $captchaResponse = $captchaValidator->validate(\Container::$request->post('g-recaptcha-response'));
if (!$captchaResponse['success']) { if (!$captchaResponse['success']) {
return new JsonContent(['error' => ['errorText' => 'reCAPTCHA challenge failed. Please try again!']]); return new JsonContent(['error' => ['errorText' => 'reCAPTCHA challenge failed. Please try again!']]);
} }
} }
$user = $this->userRepository->getByEmailOrUsername($this->request->post('email')); $user = $this->userRepository->getByEmailOrUsername(\Container::$request->post('email'));
if ($user === null) { if ($user === null) {
return new JsonContent([ return new JsonContent([
'error' => [ 'error' => [
'errorText' => 'No user found with the given email address / username.' 'errorText' => 'No user found with the given email address.'
] ]
]); ]);
} }
@ -226,15 +218,11 @@ class LoginController
$passwordResetter->setToken($token); $passwordResetter->setToken($token);
$passwordResetter->setExpiresDate($expires); $passwordResetter->setExpiresDate($expires);
\Container::$dbConnection->startTransaction();
if ($existingResetter !== null) { if ($existingResetter !== null) {
$this->pdm->deleteFromDb($existingResetter); \Container::$persistentDataManager->deleteFromDb($existingResetter);
} }
$this->pdm->saveToDb($passwordResetter); \Container::$persistentDataManager->saveToDb($passwordResetter);
\Container::$dbConnection->commit();
$this->sendPasswordResetEmail($user->getEmail(), $token, $expires); $this->sendPasswordResetEmail($user->getEmail(), $token, $expires);
@ -243,27 +231,27 @@ class LoginController
public function resetPassword(): IContent public function resetPassword(): IContent
{ {
if ($this->request->user() !== null) { if (\Container::$request->user() !== null) {
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new JsonContent([ return new JsonContent([
'redirect' => [ 'redirect' => [
'target' => '/' . $this->redirectUrl 'target' => $this->redirectUrl
] ]
]); ]);
} }
$token = $this->request->query('token'); $token = \Container::$request->query('token');
$resetter = $this->userPasswordResetterRepository->getByToken($token); $resetter = $this->userPasswordResetterRepository->getByToken($token);
if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) { if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) {
return new JsonContent([ return new JsonContent([
'redirect' => [ 'redirect' => [
'target' => '/' . \Container::$routeCollection->getRoute('password-reset')->generateLink(['token' => $token]) 'target' => \Container::$routeCollection->getRoute('password.reset')->generateLink(['token' => $token])
] ]
]); ]);
} }
if (strlen($this->request->post('password')) < 6) { if (strlen(\Container::$request->post('password')) < 6) {
return new JsonContent([ return new JsonContent([
'error' => [ 'error' => [
'errorText' => 'The given password is too short. Please choose a password that is at least 6 characters long!' 'errorText' => 'The given password is too short. Please choose a password that is at least 6 characters long!'
@ -271,22 +259,18 @@ class LoginController
]); ]);
} }
if ($this->request->post('password') !== $this->request->post('password_confirm')) { if (\Container::$request->post('password') !== \Container::$request->post('password_confirm')) {
return new JsonContent(['error' => ['errorText' => 'The given passwords do not match.']]); return new JsonContent(['error' => ['errorText' => 'The given passwords do not match.']]);
} }
\Container::$dbConnection->startTransaction(); \Container::$persistentDataManager->deleteFromDb($resetter);
$this->pdm->deleteFromDb($resetter);
$user = $this->userRepository->getById($resetter->getUserId()); $user = $this->userRepository->getById($resetter->getUserId());
$user->setPlainPassword($this->request->post('password')); $user->setPlainPassword(\Container::$request->post('password'));
$this->pdm->saveToDb($user); \Container::$persistentDataManager->saveToDb($user);
\Container::$dbConnection->commit(); \Container::$request->setUser($user);
$this->request->setUser($user);
$this->deleteRedirectUrl(); $this->deleteRedirectUrl();
return new JsonContent(['success' => true]); return new JsonContent(['success' => true]);
@ -299,8 +283,8 @@ class LoginController
$mail->setSubject($_ENV['APP_NAME'] . ' - Password reset'); $mail->setSubject($_ENV['APP_NAME'] . ' - Password reset');
$mail->setBodyFromTemplate('password-reset', [ $mail->setBodyFromTemplate('password-reset', [
'EMAIL' => $email, 'EMAIL' => $email,
'RESET_LINK' => $this->request->getBase() . '/' . 'RESET_LINK' => \Container::$request->getBase() .
\Container::$routeCollection->getRoute('password-reset')->generateLink(['token' => $token]), \Container::$routeCollection->getRoute('password.reset')->generateLink(['token' => $token]),
'EXPIRES' => $expires->format('Y-m-d H:i T') 'EXPIRES' => $expires->format('Y-m-d H:i T')
]); ]);
$mail->send(); $mail->send();
@ -308,6 +292,6 @@ class LoginController
private function deleteRedirectUrl(): void private function deleteRedirectUrl(): void
{ {
$this->request->session()->delete('redirect_after_login'); \Container::$request->session()->delete('redirect_after_login');
} }
} }

View File

@ -1,87 +0,0 @@
<?php namespace RVR\Controller;
use DateTime;
use RVR\PersistentData\Model\OAuthToken;
use RVR\PersistentData\Model\User;
use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Response\Redirect;
use SokoWeb\PersistentData\PersistentDataManager;
use SokoWeb\Response\HtmlContent;
class OAuthAuthController implements IAuthenticationRequired
{
private IRequest $request;
private PersistentDataManager $pdm;
private OAuthClientRepository $oAuthClientRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->oAuthClientRepository = new OAuthClientRepository();
}
public function isAuthenticationRequired(): bool
{
return true;
}
public function auth()
{
$redirectUri = $this->request->query('redirect_uri');
$clientId = $this->request->query('client_id');
$scope = $this->request->query('scope') ? $this->request->query('scope'): '';
$state = $this->request->query('state');
$nonce = $this->request->query('nonce') ? $this->request->query('nonce'): '';
if (!$clientId || !$redirectUri || !$state) {
return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']);
}
$client = $this->oAuthClientRepository->getByClientId($clientId);
if ($client === null) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Client is not authorized.']);
}
$redirectUriParsed = parse_url($redirectUri);
$redirectUriBase = $redirectUriParsed['scheme'] . '://' . $redirectUriParsed['host'] . $redirectUriParsed['path'];
$redirectUriQuery = [];
if (isset($redirectUriParsed['query'])) {
parse_str($redirectUriParsed['query'], $redirectUriQuery);
}
if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.']);
}
/**
* @var ?User $user
*/
$user = $this->request->user();
$code = bin2hex(random_bytes(16));
$accessToken = bin2hex(random_bytes(16));
$token = new OAuthToken();
$token->setNonce($nonce);
$token->setScope($scope);
$token->setUser($user);
$token->setCode($code);
$token->setAccessToken($accessToken);
$token->setCreatedDate(new DateTime());
$token->setExpiresDate(new DateTime('+5 minutes'));
$this->pdm->saveToDb($token);
$redirectUriQuery = array_merge($redirectUriQuery, [
'state' => $state,
'code' => $code
]);
$finalRedirectUri = $redirectUriBase . '?' . http_build_query($redirectUriQuery);
return new Redirect($finalRedirectUri, IRedirect::TEMPORARY);
}
}

View File

@ -2,85 +2,211 @@
use DateTime; use DateTime;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use Firebase\JWT\ExpiredException;
use RVR\Repository\OAuthSessionRepository;
use RVR\Repository\OAuthTokenRepository; use RVR\Repository\OAuthTokenRepository;
use RVR\Repository\UserRepository; use RVR\Repository\UserRepository;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use RVR\PersistentData\Model\OAuthSession;
use RVR\PersistentData\Model\OAuthToken;
use RVR\Repository\OAuthClientRepository; use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Response\JsonContent; use SokoWeb\Response\JsonContent;
class OAuthController class OAuthController
{ {
private IRequest $request;
private OAuthClientRepository $oAuthClientRepository; private OAuthClientRepository $oAuthClientRepository;
private OAuthSessionRepository $oAuthSessionRepository;
private OAuthTokenRepository $oAuthTokenRepository; private OAuthTokenRepository $oAuthTokenRepository;
private UserRepository $userRepository; private UserRepository $userRepository;
public function __construct(IRequest $request) public function __construct()
{ {
$this->request = $request;
$this->oAuthClientRepository = new OAuthClientRepository(); $this->oAuthClientRepository = new OAuthClientRepository();
$this->oAuthSessionRepository = new OAuthSessionRepository();
$this->oAuthTokenRepository = new OAuthTokenRepository(); $this->oAuthTokenRepository = new OAuthTokenRepository();
$this->userRepository = new UserRepository(); $this->userRepository = new UserRepository();
} }
public function getToken(): ?IContent public function generateToken(): ?IContent
{ {
$clientId = $this->request->post('client_id'); $credentials = $this->getClientCredentials();
$clientSecret = $this->request->post('client_secret'); $code = \Container::$request->post('code');
$code = $this->request->post('code'); $redirectUri = \Container::$request->post('redirect_uri');
if (!$clientId || !$clientSecret || !$code) { if (!$credentials['clientId'] || !$code || !$redirectUri) {
return new JsonContent([ return new JsonContent([
'error' => 'An invalid request was made.' 'error' => 'An invalid request was made.'
]); ]);
} }
$client = $this->oAuthClientRepository->getByClientId($clientId); $client = $this->oAuthClientRepository->getByClientId($credentials['clientId']);
if ($client === null || $client->getClientSecret() !== $clientSecret) { if ($client === null) {
return new JsonContent([ return new JsonContent([
'error' => 'Client is not authorized.' 'error' => 'Client is not found.'
]); ]);
} }
$token = $this->oAuthTokenRepository->getByCode($code); $redirectUriBase = explode('?', $redirectUri)[0];
if ($token === null || $token->getExpiresDate() < new DateTime()) { if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) {
return new JsonContent([
'error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.'
]);
}
$session = $this->oAuthSessionRepository->getByCode($code);
if ($session === null || $session->getTokenClaimed() || $session->getExpiresDate() < new DateTime()) {
return new JsonContent([ return new JsonContent([
'error' => 'The provided code is invalid.' 'error' => 'The provided code is invalid.'
]); ]);
} }
$payload = array_merge([ $codeChallenge = $session->getCodeChallenge();
if ($codeChallenge === null && $client->getClientSecret() !== $credentials['clientSecret']) {
return new JsonContent([
'error' => 'Client is not authorized.'
]);
}
if ($session->getClientId() !== $credentials['clientId']) {
return new JsonContent([
'error' => 'This code cannot be used by this client!'
]);
}
if ($codeChallenge !== null) {
$codeVerifier = \Container::$request->post('code_verifier') ?: '';
if ($session->getCodeChallengeMethod() === 'S256') {
$hash = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
} else {
$hash = $codeVerifier;
}
if ($codeChallenge !== $hash) {
return new JsonContent([
'error' => 'Code challenge failed!'
]);
}
}
$session->setTokenClaimed(true);
\Container::$persistentDataManager->saveToDb($session);
$token = new OAuthToken();
$token->setSession($session);
$token->setCreatedDate(new DateTime());
$token->setExpiresDate(new DateTime('+1 hours'));
\Container::$persistentDataManager->saveToDb($token);
$commonPayload = [
'iss' => $_ENV['APP_URL'], 'iss' => $_ENV['APP_URL'],
'iat' => (int)$token->getCreatedDate()->getTimestamp(), 'iat' => $token->getCreatedDate()->getTimestamp(),
'nbf' => (int)$token->getCreatedDate()->getTimestamp(), 'nbf' => $session->getCreatedDate()->getTimestamp(),
'exp' => (int)$token->getExpiresDate()->getTimestamp(), 'exp' => $token->getExpiresDate()->getTimestamp(),
'aud' => $clientId, 'aud' => $session->getClientId(),
'nonce' => $token->getNonce() 'nonce' => $session->getNonce()
], $this->getUserInfoInternal( ];
$this->userRepository->getById($token->getUserId()), $idTokenPayload = array_merge($commonPayload, $this->getUserInfoInternal(
$token->getScopeArray()) $this->userRepository->getById($session->getUserId()),
$session->getScopeArray())
); );
$accessTokenPayload = array_merge($commonPayload, [
'jti' => $token->getId(),
]);
$privateKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PRIVATE_KEY']); $privateKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PRIVATE_KEY']);
$jwt = JWT::encode($payload, $privateKey, 'RS256'); $idToken = JWT::encode($idTokenPayload, $privateKey, 'RS256', $_ENV['JWT_KEY_KID']);
$accessToken = JWT::encode($accessTokenPayload, $privateKey, 'RS256', $_ENV['JWT_KEY_KID']);
return new JsonContent([ return new JsonContent([
'access_token' => $token->getAccessToken(), 'access_token' => $accessToken,
'expires_in' => $token->getExpiresDate()->getTimestamp() - (new DateTime())->getTimestamp(), 'expires_in' => $token->getExpiresDate()->getTimestamp() - (new DateTime())->getTimestamp(),
'scope' => $token->getScope(), 'scope' => $session->getScope(),
'id_token' => $jwt, 'id_token' => $idToken,
'token_type' => 'Bearer' 'token_type' => 'Bearer'
]); ]);
} }
public function introspectToken(): ?IContent
{
$credentials = $this->getClientCredentials();
$accessToken = \Container::$request->post('token');
if (!$credentials['clientId'] || !$credentials['clientSecret'] || !$accessToken) {
return new JsonContent([
'error' => 'An invalid request was made.'
]);
}
$client = $this->oAuthClientRepository->getByClientId($credentials['clientId']);
if ($client === null || $client->getClientSecret() !== $credentials['clientSecret']) {
return new JsonContent([
'error' => 'Client is not authorized.'
]);
}
$tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session);
if (!$tokenValidated) {
return new JsonContent([
'active' => false
]);
}
if ($session->getClientId() !== $credentials['clientId']) {
return new JsonContent([
'active' => false
]);
}
return new JsonContent([
'active' => true,
'scope' => $session->getScope(),
'client_id' => $session->getClientId(),
'exp' => $token->getExpiresDate()->getTimestamp(),
]);
}
public function revokeToken(): ?IContent
{
$credentials = $this->getClientCredentials();
$accessToken = \Container::$request->post('token');
if (!$credentials['clientId'] || !$credentials['clientSecret'] || !$accessToken) {
return new JsonContent([
'error' => 'An invalid request was made.'
]);
}
$client = $this->oAuthClientRepository->getByClientId($credentials['clientId']);
if ($client === null || $client->getClientSecret() !== $credentials['clientSecret']) {
return new JsonContent([
'error' => 'Client is not authorized.'
]);
}
$tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session);
if (!$tokenValidated) {
return new JsonContent([]);
}
$session = $this->oAuthSessionRepository->getById($token->getSessionId());
if ($session->getClientId() !== $credentials['clientId']) {
return new JsonContent([]);
}
\Container::$persistentDataManager->deleteFromDb($token);
return new JsonContent([]);
}
public function getUserInfo() : IContent public function getUserInfo() : IContent
{ {
$authorization = $this->request->header('Authorization'); $authorization = \Container::$request->header('Authorization');
if ($authorization === null) { if ($authorization === null) {
return new JsonContent([ return new JsonContent([
'error' => 'No Authorization header was sent.' 'error' => 'No Authorization header was sent.'
@ -88,9 +214,8 @@ class OAuthController
} }
$accessToken = substr($authorization, strlen('Bearer ')); $accessToken = substr($authorization, strlen('Bearer '));
$token = $this->oAuthTokenRepository->getByAccessToken($accessToken); $tokenValidated = $this->validateTokenAndSession($accessToken, $token, $session);
if (!$tokenValidated) {
if ($token === null || $token->getExpiresDate() < new DateTime()) {
return new JsonContent([ return new JsonContent([
'error' => 'The provided access token is invalid.' 'error' => 'The provided access token is invalid.'
]); ]);
@ -98,8 +223,8 @@ class OAuthController
return new JsonContent( return new JsonContent(
$this->getUserInfoInternal( $this->getUserInfoInternal(
$this->userRepository->getById($token->getUserId()), $this->userRepository->getById($session->getUserId()),
$token->getScopeArray() $session->getScopeArray()
) )
); );
} }
@ -108,10 +233,13 @@ class OAuthController
{ {
return new JsonContent([ return new JsonContent([
'issuer' => $_ENV['APP_URL'], 'issuer' => $_ENV['APP_URL'],
'authorization_endpoint' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('oauth-auth')->generateLink(), 'authorization_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.auth')->generateLink(),
'token_endpoint' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('oauth-token')->generateLink(), 'token_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token')->generateLink(),
'userinfo_endpoint' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('oauth-userinfo')->generateLink(), 'introspection_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token.introspect')->generateLink(),
'jwks_uri' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('oauth-certs')->generateLink(), 'revocation_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.token.revoke')->generateLink(),
'userinfo_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.userinfo')->generateLink(),
'end_session_endpoint' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('logout')->generateLink(),
'jwks_uri' => \Container::$request->getBase() . \Container::$routeCollection->getRoute('oauth.certs')->generateLink(),
'response_types_supported' => 'response_types_supported' =>
[ [
'code', 'code',
@ -132,6 +260,7 @@ class OAuthController
], ],
'token_endpoint_auth_methods_supported' => 'token_endpoint_auth_methods_supported' =>
[ [
'client_secret_basic',
'client_secret_post', 'client_secret_post',
], ],
'claims_supported' => 'claims_supported' =>
@ -171,13 +300,66 @@ class OAuthController
'kty' => 'RSA', 'kty' => 'RSA',
'alg' => 'RS256', 'alg' => 'RS256',
'use' => 'sig', 'use' => 'sig',
'kid' => '1', 'kid' => $_ENV['JWT_KEY_KID'],
'n' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['n'])), 'n' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['n'])),
'e' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['e'])), 'e' => str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['e'])),
] ]
]]); ]]);
} }
private function getClientCredentials(): array
{
$authorization = \Container::$request->header('Authorization');
if ($authorization !== null) {
$basicAuthEncoded = substr($authorization, strlen('Basic '));
$basicAuth = explode(':', base64_decode($basicAuthEncoded));
if (count($basicAuth) === 2) {
$clientId = rawurldecode($basicAuth[0]);
$clientSecret = rawurldecode($basicAuth[1]);
} else {
$clientId = null;
$clientSecret = null;
}
} else {
$clientId = \Container::$request->post('client_id');
$clientSecret = \Container::$request->post('client_secret');
}
return ['clientId' => $clientId, 'clientSecret' => $clientSecret];
}
private function validateTokenAndSession(
string $accessToken,
?OAuthToken &$token,
?OAuthSession &$session): bool
{
$publicKey = file_get_contents(ROOT . '/' . $_ENV['JWT_RSA_PUBLIC_KEY']);
try {
$payload = JWT::decode($accessToken, new Key($publicKey, 'RS256'));
$token = $this->oAuthTokenRepository->getById($payload->jti);
} catch (SignatureInvalidException | BeforeValidException | ExpiredException) {
$token = null;
} catch (\UnexpectedValueException $e) {
error_log($e->getMessage() . ' Token was: ' . $accessToken);
$token = null;
}
if ($token === null || $token->getExpiresDate() < new DateTime()) {
return false;
}
$session = $this->oAuthSessionRepository->getById($token->getSessionId());
if ($session === null) {
return false;
}
return true;
}
/**
* @param User $user
* @param string[] $scope
* @return array<string, string>
*/
private function getUserInfoInternal(User $user, array $scope): array private function getUserInfoInternal(User $user, array $scope): array
{ {
$userInfo = []; $userInfo = [];

View File

@ -0,0 +1,90 @@
<?php namespace RVR\Controller;
use DateTime;
use RVR\PersistentData\Model\OAuthSession;
use RVR\PersistentData\Model\User;
use RVR\Repository\OAuthClientRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\Response\Redirect;
use SokoWeb\Response\HtmlContent;
class OAuthSessionController implements IAuthenticationRequired
{
private OAuthClientRepository $oAuthClientRepository;
public function __construct()
{
$this->oAuthClientRepository = new OAuthClientRepository();
}
public function isAuthenticationRequired(): bool
{
return true;
}
public function auth(): HtmlContent|Redirect
{
$redirectUri = \Container::$request->query('redirect_uri');
$clientId = \Container::$request->query('client_id');
$scope = \Container::$request->query('scope') ? \Container::$request->query('scope'): '';
$state = \Container::$request->query('state');
$nonce = \Container::$request->query('nonce') ? \Container::$request->query('nonce'): '';
$codeChallenge = \Container::$request->query('code_challenge') ?: null;
$codeChallengeMethod = \Container::$request->query('code_challenge_method') ?: null;
if (!$clientId || !$redirectUri || !$state || (!$codeChallenge && $codeChallengeMethod)) {
return new HtmlContent('oauth/oauth_error', ['error' => 'An invalid request was made. Please start authentication again.']);
}
if ($codeChallenge && (strlen($codeChallenge) < 43 || strlen($codeChallenge) > 128)) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Code challenge should be one between 43 and 128 characters long.']);
}
$possibleCodeChallengeMethods = ['plain', 'S256'];
if ($codeChallenge && !in_array($codeChallengeMethod, $possibleCodeChallengeMethods)) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Code challenge method should be one of the following: ' . implode(',', $possibleCodeChallengeMethods)]);
}
$client = $this->oAuthClientRepository->getByClientId($clientId);
if ($client === null) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Client is not found.']);
}
$redirectUriQueryParsed = [];
if (str_contains('?', $redirectUri)) {
[$redirectUriBase, $redirectUriQuery] = explode('?', $redirectUri, 2);
parse_str($redirectUriQuery, $redirectUriQueryParsed);
} else {
$redirectUriBase = $redirectUri;
}
if (!in_array($redirectUriBase, $client->getRedirectUrisArray())) {
return new HtmlContent('oauth/oauth_error', ['error' => 'Redirect URI \'' . $redirectUriBase .'\' is not allowed for this client.']);
}
/**
* @var ?User $user
*/
$user = \Container::$request->user();
$code = bin2hex(random_bytes(16));
$session = new OAuthSession();
$session->setClientId($clientId);
$session->setNonce($nonce);
$session->setScope($scope);
$session->setCodeChallenge($codeChallenge);
$session->setCodeChallengeMethod($codeChallengeMethod);
$session->setUser($user);
$session->setCode($code);
$session->setCreatedDate(new DateTime());
$session->setExpiresDate(new DateTime('+5 minutes'));
\Container::$persistentDataManager->saveToDb($session);
$redirectUriQueryParsed = array_merge($redirectUriQueryParsed, [
'state' => $state,
'code' => $code
]);
$finalRedirectUri = $redirectUriBase . '?' . http_build_query($redirectUriQueryParsed);
return new Redirect($finalRedirectUri, IRedirect::TEMPORARY);
}
}

View File

@ -0,0 +1,239 @@
<?php namespace RVR\Controller;
use Container;
use DateTime;
use RVR\Finance\ExchangeRateCalculator;
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\CommunityMember;
use RVR\PersistentData\Model\Transaction;
use RVR\PersistentData\Model\TransactionPayee;
use RVR\PersistentData\Model\User;
use RVR\Repository\CommunityMemberRepository;
use RVR\Repository\CommunityRepository;
use RVR\Repository\CurrencyRepository;
use RVR\Repository\TransactionRepository;
use RVR\Repository\TransactionPayeeRepository;
use RVR\Repository\EventRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Authorization\ISecured;
use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\JsonContent;
class TransactionController implements IAuthenticationRequired, ISecured
{
private CommunityRepository $communityRepository;
private CommunityMemberRepository $communityMemberRepository;
private CurrencyRepository $currencyRepository;
private TransactionRepository $transactionRepository;
private TransactionPayeeRepository $transactionPayeeRepository;
private EventRepository $eventRepository;
private ?Community $community;
private ?CommunityMember $ownCommunityMember;
public function __construct()
{
$this->communityRepository = new CommunityRepository();
$this->communityMemberRepository = new CommunityMemberRepository();
$this->currencyRepository = new CurrencyRepository();
$this->transactionRepository = new TransactionRepository();
$this->transactionPayeeRepository = new TransactionPayeeRepository();
$this->eventRepository = new EventRepository();
}
public function isAuthenticationRequired(): bool
{
return true;
}
public function authorize(): bool
{
$communitySlug = \Container::$request->query('communitySlug');
$this->community = $this->communityRepository->getBySlug($communitySlug);
if ($this->community === null) {
return false;
}
/**
* @var User $user
*/
$user = \Container::$request->user();
$this->ownCommunityMember = $this->communityMemberRepository->getByCommunityAndUser($this->community, $user);
if ($this->ownCommunityMember === null) {
return false;
}
return true;
}
public function getTransactions(): IContent
{
Container::$persistentDataManager->loadRelationsFromDb($this->community, true, ['main_currency']);
$exchangeRateCalculator = new ExchangeRateCalculator($this->community->getMainCurrency());
$eventSlug = Container::$request->query('event');
if ($eventSlug) {
$event = $this->eventRepository->getBySlug($eventSlug);
} else {
$event = null;
}
$itemsPerPage = 50;
$numberOfTransactions = $event ?
$this->transactionRepository->countAllByEvent($event) :
$this->transactionRepository->countAllByCommunity($this->community);
$currentPage = Container::$request->query('page') ?: 1;
$transactions = $event ?
$this->transactionRepository->getPagedByEvent(
$event,
$currentPage,
$itemsPerPage,
true,
['currency', 'payer_user']
) :
$this->transactionRepository->getPagedByCommunity(
$this->community,
$currentPage,
$itemsPerPage,
true,
['event', 'currency', 'payer_user']
);
$transactions = iterator_to_array($transactions);
Container::$persistentDataManager->loadMultiRelationsFromDb($transactions, 'payees', true, ['user']);
return new HtmlContent('communities/transactions', [
'community' => $this->community,
'event' => $event,
'exchangeRateCalculator' => $exchangeRateCalculator,
'pages' => ceil($numberOfTransactions / $itemsPerPage),
'currentPage' => $currentPage,
'numberOfTransactions' => $numberOfTransactions,
'transactions' => $transactions,
'members' => $this->getMembers($this->community)
]);
}
public function getTransactionEdit(): ?IContent
{
$transactionId = Container::$request->query('transactionId');
if ($transactionId) {
$transaction = $this->transactionRepository->getById($transactionId);
if ($transaction === null) {
return null;
}
Container::$persistentDataManager->loadRelationsFromDb($transaction, false, ['event']);
$event = $transaction->getEvent();
$payeeUserIds = [];
foreach ($this->transactionPayeeRepository->getAllByTransaction($transaction) as $payee) {
$payeeUserIds[] = $payee->getUserId();
}
} else {
$transaction = null;
$eventSlug = Container::$request->query('event');
if ($eventSlug) {
$event = $this->eventRepository->getBySlug($eventSlug);
} else {
$event = null;
}
$payeeUserIds = [];
}
return new HtmlContent('communities/transaction_edit', [
'community' => $this->community,
'transaction' => $transaction,
'payeeUserIds' => $payeeUserIds,
'event' => $event,
'members' => $this->getMembers($this->community),
'currencies' => $this->getCurrencies($this->community)
]);
}
public function saveTransaction(): ?IContent
{
$transactionId = Container::$request->query('transactionId');
if ($transactionId) {
$transaction = $this->transactionRepository->getById($transactionId);
} else {
$transaction = new Transaction();
$transaction->setCommunity($this->community);
}
$transaction->setEventId(Container::$request->post('event_id') ?: null);
$transaction->setCurrencyId(Container::$request->post('currency_id'));
$transaction->setPayerUserId(Container::$request->post('payer_user_id'));
$transaction->setDescription(Container::$request->post('description'));
$transaction->setSum(Container::$request->post('sum'));
$transaction->setTimeDate(new DateTime(Container::$request->post('time')));
Container::$persistentDataManager->saveToDb($transaction);
$payeeUserIds = array_unique(Container::$request->post('payee_user_ids'));
if (count($payeeUserIds) === $this->communityMemberRepository->countAllByCommunity($this->community)) {
$payeeUserIds = [];
}
$currentPayees = [];
foreach ($payeeUserIds as $payeeUserId) {
$payee = new TransactionPayee();
$payee->setTransaction($transaction);
$payee->setUserId((int)$payeeUserId);
$currentPayees[(int)$payeeUserId] = $payee;
}
$existingPayees = [];
if ($transactionId) {
foreach ($this->transactionPayeeRepository->getAllByTransaction($transaction) as $payee) {
$existingPayees[$payee->getUserId()] = $payee;
}
}
foreach (array_diff_key($currentPayees, $existingPayees) as $newPayee) {
Container::$persistentDataManager->saveToDb($newPayee);
}
foreach (array_diff_key($existingPayees, $currentPayees) as $deletedPayee) {
Container::$persistentDataManager->deleteFromDb($deletedPayee);
}
return new JsonContent(['success' => true]);
}
public function deleteTransaction(): IContent
{
$transaction = $this->transactionRepository->getById(Container::$request->query('transactionId'));
foreach ($this->transactionPayeeRepository->getAllByTransaction($transaction) as $payee) {
Container::$persistentDataManager->deleteFromDb($payee);
}
Container::$persistentDataManager->deleteFromDb($transaction);
return new JsonContent(['success' => true]);
}
private function getMembers(Community $community): array
{
$members = iterator_to_array($this->communityMemberRepository->getAllByCommunity($community, true, ['user']));
usort($members, function ($a, $b) {
return strnatcmp($a->getUser()->getDisplayName(), $b->getUser()->getDisplayName());
});
return $members;
}
private function getCurrencies(Community $community): array
{
$currencies = iterator_to_array($this->currencyRepository->getAllByCommunity($community));
usort($currencies, function ($a, $b) {
return strnatcmp($a->getCode(), $b->getCode());
});
usort($currencies, function ($a, $b) use ($community) {
return (int)($b->getId() === $community->getMainCurrencyId()) - (int)($a->getId() === $community->getMainCurrencyId());
});
return $currencies;
}
}

View File

@ -3,11 +3,9 @@
use DateTime; use DateTime;
use SokoWeb\Http\Request; use SokoWeb\Http\Request;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired; use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Interfaces\Response\IRedirect; use SokoWeb\Interfaces\Response\IRedirect;
use SokoWeb\OAuth\GoogleOAuth; use SokoWeb\OAuth\GoogleOAuth;
use SokoWeb\PersistentData\PersistentDataManager;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use SokoWeb\Response\HtmlContent; use SokoWeb\Response\HtmlContent;
use SokoWeb\Response\JsonContent; use SokoWeb\Response\JsonContent;
@ -17,16 +15,10 @@ use RVR\Repository\UserRepository;
class UserController implements IAuthenticationRequired class UserController implements IAuthenticationRequired
{ {
private IRequest $request;
private PersistentDataManager $pdm;
private UserRepository $userRepository; private UserRepository $userRepository;
public function __construct(IRequest $request) public function __construct()
{ {
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->userRepository = new UserRepository(); $this->userRepository = new UserRepository();
} }
@ -40,29 +32,153 @@ class UserController implements IAuthenticationRequired
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = \Container::$request->user();
return new HtmlContent('account/account', ['user' => $user->toArray()]); return new HtmlContent('account/account', ['user' => $user->toArray()]);
} }
public function getGoogleConnectRedirect(): IRedirect
{
/**
* @var User $user
*/
$user = \Container::$request->user();
$state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16));
\Container::$request->session()->set('oauth_state', $state);
\Container::$request->session()->set('oauth_nonce', $nonce);
$oAuth = new GoogleOAuth(new Request());
$url = $oAuth->getDialogUrl(
$state,
\Container::$request->getBase() . \Container::$routeCollection->getRoute('account.googleConnect-confirm')->generateLink(),
$nonce,
$user->getEmail()
);
return new Redirect($url, IRedirect::TEMPORARY);
}
public function getGoogleConnectConfirm(): IContent
{
$defaultError = 'Authentication with Google failed. Please <a href="' . \Container::$routeCollection->getRoute('account.googleConnect')->generateLink() . '" title="Connect with Google">try again</a>!';
if (\Container::$request->query('state') !== \Container::$request->session()->get('oauth_state')) {
return new HtmlContent('account/google_connect', ['success' => false, 'error' => $defaultError]);
}
$oAuth = new GoogleOAuth(new Request());
$tokenData = $oAuth->getToken(
\Container::$request->query('code'),
\Container::$request->getBase() . \Container::$routeCollection->getRoute('account.googleConnect-confirm')->generateLink()
);
if (!isset($tokenData['id_token'])) {
return new HtmlContent('account/google_connect', ['success' => false, 'error' => $defaultError]);
}
$jwtParser = new JwtParser($tokenData['id_token']);
$idToken = $jwtParser->getPayload();
if ($idToken['nonce'] !== \Container::$request->session()->get('oauth_nonce')) {
return new HtmlContent('account/google_connect', ['success' => false, 'error' => $defaultError]);
}
$anotherUser = $this->userRepository->getByGoogleSub($idToken['sub']);
if ($anotherUser !== null) {
return new HtmlContent('account/google_connect', [
'success' => false,
'error' => 'This Google account is linked to another account.'
]);
}
\Container::$request->session()->set('google_user_data', $idToken);
/**
* @var User $user
*/
$user = \Container::$request->user();
return new HtmlContent('account/google_connect', [
'success' => true,
'googleAccount' => $idToken['email'],
'userEmail' => $user->getEmail()
]);
}
public function connectGoogle(): IContent
{
/**
* @var User $user
*/
$user = \Container::$request->user();
if (!$user->checkPassword(\Container::$request->post('password'))) {
return new JsonContent([
'error' => [
'errorText' => 'The given password is wrong.'
]
]);
}
$googleUserData = \Container::$request->session()->get('google_user_data');
$user->setGoogleSub($googleUserData['sub']);
\Container::$persistentDataManager->saveToDb($user);
return new JsonContent(['success' => true]);
}
public function getGoogleDisconnectConfirm(): IContent
{
/**
* @var User $user
*/
$user = \Container::$request->user();
return new HtmlContent('account/google_disconnect', [
'success' => true,
'userEmail' => $user->getEmail()
]);
}
public function disconnectGoogle(): IContent
{
/**
* @var User $user
*/
$user = \Container::$request->user();
if (!$user->checkPassword(\Container::$request->post('password'))) {
return new JsonContent([
'error' => [
'errorText' => 'The given password is wrong.'
]
]);
}
$user->setGoogleSub(null);
\Container::$persistentDataManager->saveToDb($user);
return new JsonContent(['success' => true]);
}
public function getGoogleAuthenticateRedirect(): IRedirect public function getGoogleAuthenticateRedirect(): IRedirect
{ {
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = \Container::$request->user();
$state = bin2hex(random_bytes(16)); $state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16)); $nonce = bin2hex(random_bytes(16));
$this->request->session()->set('oauth_state', $state); \Container::$request->session()->set('oauth_state', $state);
$this->request->session()->set('oauth_nonce', $nonce); \Container::$request->session()->set('oauth_nonce', $nonce);
$oAuth = new GoogleOAuth(new Request()); $oAuth = new GoogleOAuth(new Request());
$url = $oAuth->getDialogUrl( $url = $oAuth->getDialogUrl(
$state, $state,
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('account.googleAuthenticate-action')->generateLink(), \Container::$request->getBase() . \Container::$routeCollection->getRoute('account.googleAuthenticate-action')->generateLink(),
$nonce, $nonce,
$user->getEmail() $user->getEmail()
); );
@ -75,16 +191,16 @@ class UserController implements IAuthenticationRequired
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = \Container::$request->user();
if ($this->request->query('state') !== $this->request->session()->get('oauth_state')) { if (\Container::$request->query('state') !== \Container::$request->session()->get('oauth_state')) {
return new HtmlContent('account/google_authenticate', ['success' => false]); return new HtmlContent('account/google_authenticate', ['success' => false]);
} }
$oAuth = new GoogleOAuth(new Request()); $oAuth = new GoogleOAuth(new Request());
$tokenData = $oAuth->getToken( $tokenData = $oAuth->getToken(
$this->request->query('code'), \Container::$request->query('code'),
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('account.googleAuthenticate-action')->generateLink() \Container::$request->getBase() . \Container::$routeCollection->getRoute('account.googleAuthenticate-action')->generateLink()
); );
if (!isset($tokenData['id_token'])) { if (!isset($tokenData['id_token'])) {
@ -94,7 +210,7 @@ class UserController implements IAuthenticationRequired
$jwtParser = new JwtParser($tokenData['id_token']); $jwtParser = new JwtParser($tokenData['id_token']);
$idToken = $jwtParser->getPayload(); $idToken = $jwtParser->getPayload();
if ($idToken['nonce'] !== $this->request->session()->get('oauth_nonce')) { if ($idToken['nonce'] !== \Container::$request->session()->get('oauth_nonce')) {
return new HtmlContent('account/google_authenticate', ['success' => false]); return new HtmlContent('account/google_authenticate', ['success' => false]);
} }
@ -106,7 +222,7 @@ class UserController implements IAuthenticationRequired
} }
$authenticatedWithGoogleUntil = new DateTime('+45 seconds'); $authenticatedWithGoogleUntil = new DateTime('+45 seconds');
$this->request->session()->set('authenticated_with_google_until', $authenticatedWithGoogleUntil); \Container::$request->session()->set('authenticated_with_google_until', $authenticatedWithGoogleUntil);
return new HtmlContent('account/google_authenticate', [ return new HtmlContent('account/google_authenticate', [
'success' => true, 'success' => true,
@ -119,18 +235,18 @@ class UserController implements IAuthenticationRequired
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = \Container::$request->user();
if (!$this->confirmUserIdentity( if (!$this->confirmUserIdentity(
$user, $user,
$this->request->session()->get('authenticated_with_google_until'), \Container::$request->session()->get('authenticated_with_google_until'),
$this->request->post('password'), \Container::$request->post('password'),
$error $error
)) { )) {
return new JsonContent(['error' => ['errorText' => $error]]); return new JsonContent(['error' => ['errorText' => $error]]);
} }
$newEmail = $this->request->post('email'); $newEmail = \Container::$request->post('email');
if ($newEmail !== $user->getEmail()) { if ($newEmail !== $user->getEmail()) {
if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) { if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) {
return new JsonContent(['error' => ['errorText' => 'Please provide a valid email address.']]); return new JsonContent(['error' => ['errorText' => 'Please provide a valid email address.']]);
@ -143,7 +259,7 @@ class UserController implements IAuthenticationRequired
$user->setEmail($newEmail); $user->setEmail($newEmail);
} }
$newUsername = $this->request->post('username'); $newUsername = \Container::$request->post('username');
if ($newUsername !== $user->getUsername()) { if ($newUsername !== $user->getUsername()) {
if (strlen($newUsername) > 0) { if (strlen($newUsername) > 0) {
if (filter_var($newUsername, FILTER_VALIDATE_EMAIL)) { if (filter_var($newUsername, FILTER_VALIDATE_EMAIL)) {
@ -160,7 +276,7 @@ class UserController implements IAuthenticationRequired
} }
} }
$newPassword = $this->request->post('password_new'); $newPassword = \Container::$request->post('password_new');
if (strlen($newPassword) > 0) { if (strlen($newPassword) > 0) {
if (strlen($newPassword) < 6) { if (strlen($newPassword) < 6) {
return new JsonContent([ return new JsonContent([
@ -170,7 +286,7 @@ class UserController implements IAuthenticationRequired
]); ]);
} }
if ($newPassword !== $this->request->post('password_new_confirm')) { if ($newPassword !== \Container::$request->post('password_new_confirm')) {
return new JsonContent([ return new JsonContent([
'error' => [ 'error' => [
'errorText' => 'The given new passwords do not match.' 'errorText' => 'The given new passwords do not match.'
@ -181,13 +297,13 @@ class UserController implements IAuthenticationRequired
$user->setPlainPassword($newPassword); $user->setPlainPassword($newPassword);
} }
$user->setNickname($this->request->post('nickname')); $user->setNickname(\Container::$request->post('nickname'));
$user->setPhone($this->request->post('phone')); $user->setPhone(\Container::$request->post('phone'));
$user->setIdNumber($this->request->post('id_number')); $user->setIdNumber(\Container::$request->post('id_number'));
$this->pdm->saveToDb($user); \Container::$persistentDataManager->saveToDb($user);
$this->request->session()->delete('authenticated_with_google_until'); \Container::$request->session()->delete('authenticated_with_google_until');
return new JsonContent(['success' => true]); return new JsonContent(['success' => true]);
} }

View File

@ -2,19 +2,15 @@
use RVR\Repository\UserRepository; use RVR\Repository\UserRepository;
use SokoWeb\Interfaces\Authentication\IAuthenticationRequired; use SokoWeb\Interfaces\Authentication\IAuthenticationRequired;
use SokoWeb\Interfaces\Request\IRequest;
use SokoWeb\Interfaces\Response\IContent; use SokoWeb\Interfaces\Response\IContent;
use SokoWeb\Response\JsonContent; use SokoWeb\Response\JsonContent;
class UserSearchController implements IAuthenticationRequired class UserSearchController implements IAuthenticationRequired
{ {
private IRequest $request;
private UserRepository $userRepository; private UserRepository $userRepository;
public function __construct(IRequest $request) public function __construct()
{ {
$this->request = $request;
$this->userRepository = new UserRepository(); $this->userRepository = new UserRepository();
} }
@ -25,7 +21,7 @@ class UserSearchController implements IAuthenticationRequired
public function searchUser(): IContent public function searchUser(): IContent
{ {
$users = iterator_to_array($this->userRepository->searchByName($this->request->query('q'))); $users = iterator_to_array($this->userRepository->searchByName(\Container::$request->query('q')));
usort($users, function($a, $b) { usort($users, function($a, $b) {
return strnatcmp($a->getDisplayName(), $b->getDisplayName()); return strnatcmp($a->getDisplayName(), $b->getDisplayName());
}); });

View File

@ -0,0 +1,18 @@
<?php namespace RVR\Database;
use SokoWeb\Database\AuditLoggerBase;
class AuditLogger extends AuditLoggerBase
{
protected function getModifierId()
{
if (\Container::$request === null) {
return null;
}
$user = \Container::$request->user();
if ($user === null) {
return null;
}
return $user->getUniqueId();
}
}

View File

@ -0,0 +1,136 @@
<?php namespace RVR\Finance;
use Container;
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\Event;
use RVR\PersistentData\Model\User;
use RVR\Repository\CommunityMemberRepository;
use RVR\Repository\TransactionRepository;
class BalanceCalculator
{
private Community $community;
private User $user;
private ?Event $event;
private TransactionRepository $transactionRepository;
private CommunityMemberRepository $communityMemberRepository;
private ExchangeRateCalculator $exchangeRateCalculator;
private array $members;
private array $payments;
private array $actualDebts;
public function __construct(Community $community, User $user, ?Event $event = null)
{
$this->community = $community;
$this->user = $user;
$this->event = $event;
$this->transactionRepository = new TransactionRepository();
$this->communityMemberRepository = new CommunityMemberRepository();
$this->exchangeRateCalculator = new ExchangeRateCalculator($this->community->getMainCurrency());
}
public function calculate(): array
{
$this->collectMembers();
$this->createPaymentsMatrix();
$this->sumTransactions();
$this->calculateActualDebts();
return $this->calculateBalanceForUser();
}
private function collectMembers(): void
{
$this->members = [];
foreach ($this->communityMemberRepository->getAllByCommunity($this->community, true, ['user']) as $member) {
$this->members[$member->getUserId()] = $member;
}
}
private function createPaymentsMatrix(): void
{
$this->payments = [];
foreach ($this->members as $payerUserId => $member) {
$this->payments[$payerUserId] = [];
foreach ($this->members as $payeeUserId => $member) {
$this->payments[$payerUserId][$payeeUserId] = 0;
}
}
}
private function sumTransactions(): void
{
$membersCount = count($this->members);
if ($this->event !== null) {
$transactions = iterator_to_array($this->transactionRepository->getAllByEvent($this->event, true, ['currency']));
} else {
$transactions = iterator_to_array($this->transactionRepository->getAllByCommunity($this->community, true, ['currency']));
}
Container::$persistentDataManager->loadMultiRelationsFromDb($transactions, 'payees');
foreach ($transactions as $transaction) {
$sum = $this->exchangeRateCalculator->calculate($transaction->getSum(), $transaction->getCurrency(), $transaction->getTimeDate());
$payees = $transaction->getPayees();
$payeeCount = count($payees);
if ($payeeCount > 0) {
foreach ($payees as $payee) {
$this->payments[$transaction->getPayerUserId()][$payee->getUserId()] += $sum / $payeeCount;
}
} else {
foreach ($this->members as $payeeUserId => $member) {
$this->payments[$transaction->getPayerUserId()][$payeeUserId] += $sum / $membersCount;
}
}
}
}
private function calculateActualDebts(): void
{
$this->actualDebts = [];
foreach ($this->payments as $payerUserId => $paymentsOfPayer) {
foreach ($paymentsOfPayer as $payeeUserId => $sum) {
$actualDebt = $this->payments[$payeeUserId][$payerUserId] - $sum;
if (round($actualDebt, $this->community->getMainCurrency()->getRoundDigits()) > 0.0) {
$this->actualDebts[] = ['payer' => $this->members[$payerUserId], 'payee' => $this->members[$payeeUserId], 'amount' => $actualDebt];
}
}
}
}
private function calculateBalanceForUser(): array
{
$debtItems = [];
$debtBalance = 0.0;
$outstandingItems = [];
$outstandingBalance = 0.0;
foreach ($this->actualDebts as $debt) {
if ($debt['payer']->getId() === $this->user->getId()) {
$debtBalance += $debt['amount'];
$debtItems[] = $debt;
}
if ($debt['payee']->getId() === $this->user->getId()) {
$outstandingBalance += $debt['amount'];
$outstandingItems[] = $debt;
}
}
$absoluteBalance = $outstandingBalance - $debtBalance;
return [
'absoluteBalance' => $absoluteBalance,
'debtItems' => $debtItems,
'debtBalance' => $debtBalance,
'outstandingItems' => $outstandingItems,
'outstandingBalance' => $outstandingBalance
];
}
}

View File

@ -0,0 +1,52 @@
<?php namespace RVR\Finance;
use DateTime;
use RVR\PersistentData\Model\Currency;
use RVR\PersistentData\Model\CurrencyExchangeRate;
use RVR\Repository\CurrencyExchangeRateRepository;
class ExchangeRateCalculator
{
private Currency $mainCurrency;
private CurrencyExchangeRateRepository $currencyExchangeRateRepository;
private array $exchangeRates = [];
public function __construct(Currency $mainCurrency)
{
$this->mainCurrency = $mainCurrency;
$this->currencyExchangeRateRepository = new CurrencyExchangeRateRepository();
}
public function calculate(float $sumInCurrency, Currency $currency, DateTime $time): float
{
if ($currency->getId() === $this->mainCurrency->getId()) {
return $sumInCurrency;
}
$currentExchangeRate = $this->getCurrentExchangeRate($currency, $time);
if ($currentExchangeRate === null) {
return 0.0;
}
return $sumInCurrency * $currentExchangeRate->getExchangeRate();
}
private function getCurrentExchangeRate(Currency $currency, DateTime $time): ?CurrencyExchangeRate
{
if (!isset($this->exchangeRates[$currency->getId()])) {
$this->exchangeRates[$currency->getId()] = iterator_to_array($this->currencyExchangeRateRepository->getAllByCurrency($currency));
}
$currentExchangeRate = null;
foreach ($this->exchangeRates[$currency->getId()] as $exchangeRate) {
if ($exchangeRate->getValidFromDate() > $time) {
break;
}
$currentExchangeRate = $exchangeRate;
}
return $currentExchangeRate;
}
}

View File

@ -1,18 +1,26 @@
<?php namespace RVR\PersistentData\Model; <?php namespace RVR\PersistentData\Model;
use DateTime; use DateTime;
use SokoWeb\PersistentData\Model\Model; use SokoWeb\PersistentData\Model\ModelWithSlug;
class Community extends Model class Community extends ModelWithSlug
{ {
protected static string $table = 'communities'; protected static string $table = 'communities';
protected static array $fields = ['name', 'currency', 'created']; protected static array $fields = ['name', 'currency', 'main_currency_id', 'created'];
protected static array $relations = ['main_currency' => Currency::class];
protected static string $slugSource = 'name';
private string $name = ''; private string $name = '';
private string $currency = ''; private string $currency = '';
private ?Currency $mainCurrency = null;
private ?int $mainCurrencyId = null;
private DateTime $created; private DateTime $created;
public function setName(string $name): void public function setName(string $name): void
@ -25,6 +33,16 @@ class Community extends Model
$this->currency = $currency; $this->currency = $currency;
} }
public function setMainCurrency(?Currency $mainCurrency): void
{
$this->mainCurrency = $mainCurrency;
}
public function setMainCurrencyId(?int $mainCurrencyId): void
{
$this->mainCurrencyId = $mainCurrencyId;
}
public function setCreatedDate(DateTime $created): void public function setCreatedDate(DateTime $created): void
{ {
$this->created = $created; $this->created = $created;
@ -45,6 +63,16 @@ class Community extends Model
return $this->currency; return $this->currency;
} }
public function getMainCurrency(): ?Currency
{
return $this->mainCurrency;
}
public function getMainCurrencyId(): ?int
{
return $this->mainCurrencyId;
}
public function getCreatedDate(): DateTime public function getCreatedDate(): DateTime
{ {
return $this->created; return $this->created;

View File

@ -0,0 +1,60 @@
<?php namespace RVR\PersistentData\Model;
use SokoWeb\PersistentData\Model\Model;
class Currency extends Model
{
protected static string $table = 'currencies';
protected static array $fields = ['community_id', 'code', 'round_digits'];
protected static array $relations = ['community' => Community::class];
private ?Community $community = null;
private ?int $communityId = null;
private string $code = '';
private int $roundDigits = 0;
public function setCommunity(Community $community): void
{
$this->community = $community;
}
public function setCommunityId(int $communityId): void
{
$this->communityId = $communityId;
}
public function setCode(string $code): void
{
$this->code = $code;
}
public function setRoundDigits(int $roundDigits): void
{
$this->roundDigits = $roundDigits;
}
public function getCommunity(): ?Community
{
return $this->community;
}
public function getCommunityId(): ?int
{
return $this->communityId;
}
public function getCode(): string
{
return $this->code;
}
public function getRoundDigits(): int
{
return $this->roundDigits;
}
}

View File

@ -0,0 +1,71 @@
<?php namespace RVR\PersistentData\Model;
use DateTime;
use SokoWeb\PersistentData\Model\Model;
class CurrencyExchangeRate extends Model
{
protected static string $table = 'currency_exchange_rates';
protected static array $fields = ['currency_id', 'exchange_rate', 'valid_from'];
protected static array $relations = ['currency' => Currency::class];
private ?Currency $currency = null;
private ?int $currencyId = null;
private float $exchangeRate = 0.0;
private DateTime $validFrom;
public function setCurrency(Currency $currency): void
{
$this->currency = $currency;
}
public function setCurrencyId(int $currencyId): void
{
$this->currencyId = $currencyId;
}
public function setExchangeRate(float $exchangeRate): void
{
$this->exchangeRate = $exchangeRate;
}
public function setValidFromDate(DateTime $validFrom): void
{
$this->validFrom = $validFrom;
}
public function setValidFrom(string $validFrom): void
{
$this->validFrom = new DateTime($validFrom);
}
public function getCurrency(): ?Currency
{
return $this->currency;
}
public function getCurrencyId(): ?int
{
return $this->currencyId;
}
public function getExchangeRate(): float
{
return $this->exchangeRate;
}
public function getValidFromDate(): DateTime
{
return $this->validFrom;
}
public function getValidFrom(): string
{
return $this->validFrom->format('Y-m-d H:i:s');
}
}

View File

@ -0,0 +1,107 @@
<?php namespace RVR\PersistentData\Model;
use DateTime;
use SokoWeb\PersistentData\Model\ModelWithSlug;
class Event extends ModelWithSlug
{
protected static string $table = 'events';
protected static array $fields = ['community_id', 'start', 'end', 'title', 'description'];
protected static array $relations = ['community' => Community::class];
protected static string $slugSource = 'title';
private ?Community $community = null;
private int $communityId;
private DateTime $start;
private DateTime $end;
private string $title = '';
private string $description = '';
public function setCommunity(Community $community): void
{
$this->community = $community;
}
public function setCommunityId(int $communityId): void
{
$this->communityId = $communityId;
}
public function setStartDate(DateTime $start): void
{
$this->start = $start;
}
public function setStart(string $start): void
{
$this->start = new DateTime($start);
}
public function setEndDate(DateTime $end): void
{
$this->end = $end;
}
public function setEnd(string $end): void
{
$this->end = new DateTime($end);
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function setDescription(string $description): void
{
$this->description = $description;
}
public function getCommunity(): ?Community
{
return $this->community;
}
public function getCommunityId(): int
{
return $this->communityId;
}
public function getStartDate(): DateTime
{
return $this->start;
}
public function getStart(): string
{
return $this->start->format('Y-m-d H:i:s');
}
public function getEndDate(): DateTime
{
return $this->end;
}
public function getEnd(): string
{
return $this->end->format('Y-m-d H:i:s');
}
public function getTitle(): string
{
return $this->title;
}
public function getDescription(): string
{
return $this->description;
}
}

View File

@ -0,0 +1,182 @@
<?php namespace RVR\PersistentData\Model;
use DateTime;
use SokoWeb\PersistentData\Model\Model;
class OAuthSession extends Model
{
protected static string $table = 'oauth_sessions';
protected static array $fields = ['client_id', 'scope', 'nonce', 'code_challenge', 'code_challenge_method', 'user_id', 'code', 'created', 'expires', 'token_claimed'];
protected static array $relations = ['user' => User::class];
private static array $possibleScopeValues = ['openid', 'email', 'profile', 'union_profile'];
private static array $possibleCodeChallengeMethodValues = ['plain', 'S256'];
private string $clientId = '';
private array $scope = [];
private string $nonce = '';
private ?string $codeChallenge = null;
private ?string $codeChallengeMethod = null;
private ?User $user = null;
private ?int $userId = null;
private string $code = '';
private DateTime $created;
private DateTime $expires;
private bool $tokenClaimed = false;
public function setScopeArray(array $scope): void
{
$this->scope = array_intersect($scope, self::$possibleScopeValues);
}
public function setClientId(string $clientId): void
{
$this->clientId = $clientId;
}
public function setScope(string $scope): void
{
$this->setScopeArray(explode(' ', $scope));
}
public function setNonce(string $nonce): void
{
$this->nonce = $nonce;
}
public function setCodeChallenge(?string $codeChallenge): void
{
$this->codeChallenge = $codeChallenge;
}
public function setCodeChallengeMethod(?string $codeChallengeMethod): void
{
if ($codeChallengeMethod !== null && !in_array($codeChallengeMethod, self::$possibleCodeChallengeMethodValues)) {
throw new \UnexpectedValueException($codeChallengeMethod . ' is not possible for challengeMethod!');
}
$this->codeChallengeMethod = $codeChallengeMethod;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function setCode(string $code): void
{
$this->code = $code;
}
public function setCreatedDate(DateTime $created): void
{
$this->created = $created;
}
public function setExpiresDate(DateTime $expires): void
{
$this->expires = $expires;
}
public function setCreated(string $created): void
{
$this->created = new DateTime($created);
}
public function setExpires(string $expires): void
{
$this->expires = new DateTime($expires);
}
public function setTokenClaimed(bool $tokenClaimed): void
{
$this->tokenClaimed = $tokenClaimed;
}
public function getClientId(): string
{
return $this->clientId;
}
public function getScope(): string
{
return implode(' ', $this->scope);
}
public function getScopeArray(): array
{
return $this->scope;
}
public function getNonce(): string
{
return $this->nonce;
}
public function getCodeChallenge(): ?string
{
return $this->codeChallenge;
}
public function getCodeChallengeMethod(): ?string
{
return $this->codeChallengeMethod;
}
public function getUser(): ?User
{
return $this->user;
}
public function getUserId(): ?int
{
return $this->userId;
}
public function getCode(): string
{
return $this->code;
}
public function getCreatedDate(): DateTime
{
return $this->created;
}
public function getCreated(): string
{
return $this->created->format('Y-m-d H:i:s');
}
public function getExpiresDate(): DateTime
{
return $this->expires;
}
public function getExpires(): string
{
return $this->expires->format('Y-m-d H:i:s');
}
public function getTokenClaimed(): bool
{
return $this->tokenClaimed;
}
}

View File

@ -7,61 +7,26 @@ class OAuthToken extends Model
{ {
protected static string $table = 'oauth_tokens'; protected static string $table = 'oauth_tokens';
protected static array $fields = ['scope', 'nonce', 'user_id', 'code', 'access_token', 'created', 'expires']; protected static array $fields = ['session_id', 'created', 'expires'];
protected static array $relations = ['user' => User::class]; protected static array $relations = ['session' => OAuthSession::class];
private static array $possibleScopeValues = ['openid', 'email', 'profile']; private ?OAuthSession $session = null;
private array $scope = []; private ?int $sessionId = null;
private string $nonce = '';
private ?User $user = null;
private ?int $userId = null;
private string $code = '';
private string $accessToken = '';
private DateTime $created; private DateTime $created;
private DateTime $expires; private DateTime $expires;
public function setScopeArray(array $scope): void public function setSession(OAuthSession $session): void
{ {
$this->scope = array_intersect($scope, self::$possibleScopeValues); $this->session = $session;
} }
public function setScope(string $scope): void public function setSessionId(int $sessionId): void
{ {
$this->setScopeArray(explode(' ', $scope)); $this->sessionId = $sessionId;
}
public function setNonce(string $nonce): void
{
$this->nonce = $nonce;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function setCode(string $code): void
{
$this->code = $code;
}
public function setAccessToken(string $accessToken): void
{
$this->accessToken = $accessToken;
} }
public function setCreatedDate(DateTime $created): void public function setCreatedDate(DateTime $created): void
@ -84,39 +49,14 @@ class OAuthToken extends Model
$this->expires = new DateTime($expires); $this->expires = new DateTime($expires);
} }
public function getScope(): string public function getSession(): ?OAuthSession
{ {
return implode(' ', $this->scope); return $this->session;
} }
public function getScopeArray(): array public function getSessionId(): ?int
{ {
return $this->scope; return $this->sessionId;
}
public function getNonce(): string
{
return $this->nonce;
}
public function getUser(): ?User
{
return $this->user;
}
public function getUserId(): ?int
{
return $this->userId;
}
public function getCode(): string
{
return $this->code;
}
public function getAccessToken(): string
{
return $this->accessToken;
} }
public function getCreatedDate(): DateTime public function getCreatedDate(): DateTime

View File

@ -0,0 +1,201 @@
<?php namespace RVR\PersistentData\Model;
use DateTime;
use SokoWeb\PersistentData\Model\Model;
class Transaction extends Model
{
protected static string $table = 'transactions';
protected static array $fields = ['community_id', 'event_id', 'currency_id', 'payer_user_id', 'payee_user_id', 'description', 'sum', 'time'];
protected static array $relations = [
'community' => Community::class,
'event' => Event::class,
'currency' => Currency::class,
'payer_user' => User::class,
'payee_user' => User::class
];
protected static array $multiRelations = [
'payees' => [TransactionPayee::class, 'transaction']
];
private ?Community $community = null;
private int $communityId;
private ?Event $event = null;
private ?int $eventId = null;
private ?Currency $currency = null;
private int $currencyId;
private ?User $payerUser = null;
private int $payerUserId;
private ?User $payeeUser = null;
private ?int $payeeUserId = null;
private ?array $payees = null;
private string $description = '';
private float $sum = 0.0;
private DateTime $time;
public function setCommunity(Community $community): void
{
$this->community = $community;
}
public function setCommunityId(int $communityId): void
{
$this->communityId = $communityId;
}
public function setEvent(?Event $event): void
{
$this->event = $event;
}
public function setEventId(?int $eventId): void
{
$this->eventId = $eventId;
}
public function setCurrency(Currency $currency): void
{
$this->currency = $currency;
}
public function setCurrencyId(int $currencyId): void
{
$this->currencyId = $currencyId;
}
public function setPayerUser(User $payerUser): void
{
$this->payerUser = $payerUser;
}
public function setPayerUserId(int $payerUserId): void
{
$this->payerUserId = $payerUserId;
}
public function setPayeeUser(?User $payeeUser): void
{
$this->payeeUser = $payeeUser;
}
public function setPayeeUserId(?int $payeeUserId): void
{
$this->payeeUserId = $payeeUserId;
}
public function setPayees(array $payees): void
{
$this->payees = $payees;
}
public function setDescription(string $description): void
{
$this->description = $description;
}
public function setSum(float $sum): void
{
$this->sum = $sum;
}
public function setTimeDate(DateTime $time): void
{
$this->time = $time;
}
public function setTime(string $time): void
{
$this->time = new DateTime($time);
}
public function getCommunity(): ?Community
{
return $this->community;
}
public function getCommunityId(): int
{
return $this->communityId;
}
public function getEvent(): ?Event
{
return $this->event;
}
public function getEventId(): ?int
{
return $this->eventId;
}
public function getCurrency(): ?Currency
{
return $this->currency;
}
public function getCurrencyId(): int
{
return $this->currencyId;
}
public function getPayerUser(): ?User
{
return $this->payerUser;
}
public function getPayerUserId(): int
{
return $this->payerUserId;
}
public function getPayeeUser(): ?User
{
return $this->payeeUser;
}
public function getPayeeUserId(): ?int
{
return $this->payeeUserId;
}
public function getPayees(): ?array
{
return $this->payees;
}
public function getDescription(): string
{
return $this->description;
}
public function getSum(): float
{
return $this->sum;
}
public function getTimeDate(): DateTime
{
return $this->time;
}
public function getTime(): string
{
return $this->time->format('Y-m-d H:i:s');
}
}

View File

@ -0,0 +1,60 @@
<?php namespace RVR\PersistentData\Model;
use SokoWeb\PersistentData\Model\Model;
class TransactionPayee extends Model
{
protected static string $table = 'transaction_payees';
protected static array $fields = ['transaction_id', 'user_id'];
protected static array $relations = ['transaction' => Transaction::class, 'user' => User::class];
private ?Transaction $transaction = null;
private ?int $transactionId = null;
private ?User $user = null;
private ?int $userId = null;
public function setTransaction(Transaction $transaction): void
{
$this->transaction = $transaction;
}
public function setTransactionId(int $transactionId): void
{
$this->transactionId = $transactionId;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function getTransaction(): ?Transaction
{
return $this->transaction;
}
public function getTransactionId(): ?int
{
return $this->transactionId;
}
public function getUser(): ?User
{
return $this->user;
}
public function getUserId(): ?int
{
return $this->userId;
}
}

View File

@ -5,36 +5,32 @@ use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\CommunityMember; use RVR\PersistentData\Model\CommunityMember;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use SokoWeb\Database\Query\Select; use SokoWeb\Database\Query\Select;
use SokoWeb\PersistentData\PersistentDataManager;
class CommunityMemberRepository class CommunityMemberRepository
{ {
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $id): ?CommunityMember public function getById(int $id): ?CommunityMember
{ {
return $this->pdm->selectFromDbById($id, CommunityMember::class); return \Container::$persistentDataManager->selectFromDbById($id, CommunityMember::class);
} }
public function getAllByCommunity(Community $community, bool $useRelations = false): Generator public function getAllByCommunity(Community $community, bool $useRelations = false, array $withRelations = []): Generator
{ {
$select = new Select(\Container::$dbConnection); $select = $this->selectAllByCommunity($community);
$select->where('community_id', '=', $community->getId());
yield from $this->pdm->selectMultipleFromDb($select, CommunityMember::class, $useRelations); yield from \Container::$persistentDataManager->selectMultipleFromDb($select, CommunityMember::class, $useRelations, $withRelations);
} }
public function getAllByUser(User $user, bool $useRelations = false): Generator public function countAllByCommunity(Community $community): int
{
return $this->selectAllByCommunity($community)->count();
}
public function getAllByUser(User $user, bool $useRelations = false, array $withRelations = []): Generator
{ {
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('user_id', '=', $user->getId()); $select->where('user_id', '=', $user->getId());
yield from $this->pdm->selectMultipleFromDb($select, CommunityMember::class, $useRelations); yield from \Container::$persistentDataManager->selectMultipleFromDb($select, CommunityMember::class, $useRelations, $withRelations);
} }
public function getByCommunityAndUser(Community $community, User $user) : ?CommunityMember public function getByCommunityAndUser(Community $community, User $user) : ?CommunityMember
@ -43,6 +39,13 @@ class CommunityMemberRepository
$select->where('community_id', '=', $community->getId()); $select->where('community_id', '=', $community->getId());
$select->where('user_id', '=', $user->getId()); $select->where('user_id', '=', $user->getId());
return $this->pdm->selectFromDb($select, CommunityMember::class); return \Container::$persistentDataManager->selectFromDb($select, CommunityMember::class);
}
private function selectAllByCommunity(Community $community): Select
{
$select = new Select(\Container::$dbConnection, CommunityMember::getTable());
$select->where('community_id', '=', $community->getId());
return $select;
} }
} }

View File

@ -1,19 +1,16 @@
<?php namespace RVR\Repository; <?php namespace RVR\Repository;
use RVR\PersistentData\Model\Community; use RVR\PersistentData\Model\Community;
use SokoWeb\PersistentData\PersistentDataManager;
class CommunityRepository class CommunityRepository
{ {
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $id): ?Community public function getById(int $id): ?Community
{ {
return $this->pdm->selectFromDbById($id, Community::class); return \Container::$persistentDataManager->selectFromDbById($id, Community::class);
}
public function getBySlug(string $slug): ?Community
{
return \Container::$persistentDataManager->selectFromDbBySlug($slug, Community::class);
} }
} }

View File

@ -0,0 +1,24 @@
<?php namespace RVR\Repository;
use Generator;
use Container;
use RVR\PersistentData\Model\Currency;
use RVR\PersistentData\Model\CurrencyExchangeRate;
use SokoWeb\Database\Query\Select;
class CurrencyExchangeRateRepository
{
public function getById(int $id): ?CurrencyExchangeRate
{
return \Container::$persistentDataManager->selectFromDbById($id, CurrencyExchangeRate::class);
}
public function getAllByCurrency(Currency $currency): Generator
{
$select = new Select(Container::$dbConnection);
$select->where('currency_id', '=', $currency->getId());
$select->orderBy('valid_from');
yield from Container::$persistentDataManager->selectMultipleFromDb($select, CurrencyExchangeRate::class);
}
}

View File

@ -0,0 +1,32 @@
<?php namespace RVR\Repository;
use Generator;
use Container;
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\Currency;
use SokoWeb\Database\Query\Select;
class CurrencyRepository
{
public function getById(int $id): ?Currency
{
return \Container::$persistentDataManager->selectFromDbById($id, Currency::class);
}
public function getAllByCommunity(Community $community, bool $useRelations = false): Generator
{
$select = new Select(Container::$dbConnection);
$select->where('community_id', '=', $community->getId());
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Currency::class, $useRelations);
}
public function getByCommunityAndCurrencyCode(Community $community, string $code): ?Currency
{
$select = new Select(Container::$dbConnection);
$select->where('community_id', '=', $community->getId());
$select->where('code', '=', $code);
return Container::$persistentDataManager->selectFromDb($select, Currency::class);
}
}

View File

@ -0,0 +1,119 @@
<?php namespace RVR\Repository;
use Container;
use DateTime;
use DateInterval;
use Generator;
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\Event;
use RVR\PersistentData\Model\User;
use SokoWeb\Database\Query\Select;
class EventRepository
{
public function getById(int $id): ?Event
{
return Container::$persistentDataManager->selectFromDbById($id, Event::class);
}
public function getBySlug(string $slug): ?Event
{
return \Container::$persistentDataManager->selectFromDbBySlug($slug, Event::class);
}
public function getAllByCommunity(Community $community, bool $useRelations = false, array $withRelations = []): Generator
{
$select = new Select(Container::$dbConnection, Event::getTable());
$this->selectAllByCommunity($select, $community);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Event::class, $useRelations, $withRelations);
}
public function countAllByCommunity(Community $community): int
{
$select = new Select(Container::$dbConnection, Event::getTable());
$this->selectAllByCommunity($select, $community);
return $select->count();
}
public function getUpcomingAndRecentByCommunity(Community $community, DateTime $from, int $days, int $limit, bool $useRelations = false, array $withRelations = []): Generator
{
$select = new Select(Container::$dbConnection, Event::getTable());
$this->selectAllByCommunity($select, $community);
$this->selectUpcomingAndRecent($select, $from, $days);
$select->orderBy('start', 'DESC');
$select->limit($limit, 0);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Event::class, $useRelations, $withRelations);
}
public function getUpcomingAndRecentByUser(User $user, DateTime $from, int $days, int $limit, bool $useRelations = false, array $withRelations = []): Generator
{
$select = new Select(Container::$dbConnection, Event::getTable());
$this->selectAllByUser($select, $user);
$this->selectUpcomingAndRecent($select, $from, $days);
$select->orderBy('start', 'DESC');
$select->limit($limit, 0);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Event::class, $useRelations, $withRelations);
}
public function getCurrentByUser(User $user, DateTime $from, int $days, bool $useRelations = false, array $withRelations = []): ?Event
{
$select = new Select(Container::$dbConnection, Event::getTable());
$this->selectAllByUser($select, $user);
$this->selectUpcomingAndRecent($select, $from, $days);
$select->orderBy('start', 'DESC');
$select->limit(1, 0);
return Container::$persistentDataManager->selectFromDb($select, Event::class, $useRelations, $withRelations);
}
public function getPagedByCommunity(Community $community, int $page, int $itemsPerPage, bool $useRelations = false, array $withRelations = []): Generator
{
$select = new Select(Container::$dbConnection, Event::getTable());
$this->selectAllByCommunity($select, $community);
$select->orderBy('start', 'DESC');
$select->paginate($page, $itemsPerPage);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Event::class, $useRelations, $withRelations);
}
public function searchByTitle(Community $community, string $title): Generator
{
$select = new Select(Container::$dbConnection, Event::getTable());
$this->selectAllByCommunity($select, $community);
$select->where('title', 'LIKE', '%' . $title . '%');
$select->orderBy('start', 'DESC');
$select->limit(10);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Event::class);
}
private function selectAllByCommunity(Select $select, Community $community): void
{
$select->where('community_id', '=', $community->getId());
}
private function selectAllByUser(Select $select, User $user): void
{
$select->innerJoin('communities', ['communities', 'id'], '=', ['events', 'community_id']);
$select->innerJoin('community_members', ['communities', 'id'], '=', ['community_members', 'community_id']);
$select->where(['community_members', 'user_id'], '=', $user->getId());
}
private function selectUpcomingAndRecent(Select $select, DateTime $from, int $days): void
{
$select->where(function (Select $select) use ($from, $days) {
$select->where(function (Select $select) use ($from, $days) {
$select->where('start', '<', (clone $from)->add(DateInterval::createFromDateString("$days days"))->format('Y-m-d H:i:s'));
$select->where('end', '>', $from->format('Y-m-d H:i:s'));
});
$select->orWhere(function (Select $select) use ($from, $days) {
$select->where('end', '>', (clone $from)->sub(DateInterval::createFromDateString("$days days"))->format('Y-m-d H:i:s'));
$select->where('start', '<', $from->format('Y-m-d H:i:s'));
});
});
}
}

View File

@ -2,20 +2,12 @@
use SokoWeb\Database\Query\Select; use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\OAuthClient; use RVR\PersistentData\Model\OAuthClient;
use SokoWeb\PersistentData\PersistentDataManager;
class OAuthClientRepository class OAuthClientRepository
{ {
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $id): ?OAuthClient public function getById(int $id): ?OAuthClient
{ {
return $this->pdm->selectFromDbById($id, OAuthClient::class); return \Container::$persistentDataManager->selectFromDbById($id, OAuthClient::class);
} }
public function getByClientId(string $clientId): ?OAuthClient public function getByClientId(string $clientId): ?OAuthClient
@ -23,6 +15,6 @@ class OAuthClientRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('client_id', '=', $clientId); $select->where('client_id', '=', $clientId);
return $this->pdm->selectFromDb($select, OAuthClient::class); return \Container::$persistentDataManager->selectFromDb($select, OAuthClient::class);
} }
} }

View File

@ -0,0 +1,30 @@
<?php namespace RVR\Repository;
use DateTime;
use Generator;
use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\OAuthSession;
class OAuthSessionRepository
{
public function getById(int $id): ?OAuthSession
{
return \Container::$persistentDataManager->selectFromDbById($id, OAuthSession::class);
}
public function getByCode(string $code): ?OAuthSession
{
$select = new Select(\Container::$dbConnection);
$select->where('code', '=', $code);
return \Container::$persistentDataManager->selectFromDb($select, OAuthSession::class);
}
public function getAllExpired(): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s'));
yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthSession::class);
}
}

View File

@ -4,36 +4,25 @@ use DateTime;
use Generator; use Generator;
use SokoWeb\Database\Query\Select; use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\OAuthToken; use RVR\PersistentData\Model\OAuthToken;
use SokoWeb\PersistentData\PersistentDataManager; use RVR\PersistentData\Model\OAuthSession;
class OAuthTokenRepository class OAuthTokenRepository
{ {
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $id): ?OAuthToken public function getById(int $id): ?OAuthToken
{ {
return $this->pdm->selectFromDbById($id, OAuthToken::class); return \Container::$persistentDataManager->selectFromDbById($id, OAuthToken::class);
} }
public function getByCode(string $code): ?OAuthToken public function getAllBySession(OAuthSession $session, bool $useRelations = false, array $withRelations = []): Generator
{ {
$select = new Select(\Container::$dbConnection); $select = $this->selectAllBySession($session);
$select->where('code', '=', $code);
return $this->pdm->selectFromDb($select, OAuthToken::class); yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthToken::class, $useRelations, $withRelations);
} }
public function getByAccessToken(string $accessToken): ?OAuthToken public function countAllBySession(OAuthSession $session): int
{ {
$select = new Select(\Container::$dbConnection); return $this->selectAllBySession($session)->count();
$select->where('access_token', '=', $accessToken);
return $this->pdm->selectFromDb($select, OAuthToken::class);
} }
public function getAllExpired(): Generator public function getAllExpired(): Generator
@ -41,6 +30,13 @@ class OAuthTokenRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s')); $select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s'));
yield from $this->pdm->selectMultipleFromDb($select, OAuthToken::class); yield from \Container::$persistentDataManager->selectMultipleFromDb($select, OAuthToken::class);
}
private function selectAllBySession(OAuthSession $session): Select
{
$select = new Select(\Container::$dbConnection, OAuthToken::getTable());
$select->where('session_id', '=', $session->getId());
return $select;
} }
} }

View File

@ -0,0 +1,23 @@
<?php namespace RVR\Repository;
use Container;
use Generator;
use RVR\PersistentData\Model\Transaction;
use RVR\PersistentData\Model\TransactionPayee;
use SokoWeb\Database\Query\Select;
class TransactionPayeeRepository
{
public function getById(int $id): ?TransactionPayee
{
return Container::$persistentDataManager->selectFromDbById($id, TransactionPayee::class);
}
public function getAllByTransaction(Transaction $transaction, bool $useRelations = false, array $withRelations = []): Generator
{
$select = new Select(Container::$dbConnection);
$select->where('transaction_id', '=', $transaction->getId());
yield from Container::$persistentDataManager->selectMultipleFromDb($select, TransactionPayee::class, $useRelations, $withRelations);
}
}

View File

@ -0,0 +1,100 @@
<?php namespace RVR\Repository;
use Container;
use Generator;
use RVR\PersistentData\Model\Community;
use RVR\PersistentData\Model\Currency;
use RVR\PersistentData\Model\Event;
use RVR\PersistentData\Model\Transaction;
use RVR\PersistentData\Model\User;
use SokoWeb\Database\Query\Select;
class TransactionRepository
{
public function getById(int $id): ?Transaction
{
return Container::$persistentDataManager->selectFromDbById($id, Transaction::class);
}
public function getAllByCommunity(Community $community, bool $useRelations = false, array $withRelations = []): Generator
{
$select = $this->selectAllByCommunity($community);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Transaction::class, $useRelations, $withRelations);
}
public function getAllByEvent(Event $event, bool $useRelations = false, array $withRelations = []): Generator
{
$select = $this->selectAllByEvent($event);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Transaction::class, $useRelations, $withRelations);
}
public function countAllByCommunity(Community $community): int
{
return $this->selectAllByCommunity($community)->count();
}
public function countAllByEvent(Event $event): int
{
return $this->selectAllByEvent($event)->count();
}
public function isAnyForUser(User $user): bool
{
$select = new Select(Container::$dbConnection, Transaction::getTable());
$select->where('payer_user_id', '=', $user->getId());
$select->orWhere('payee_user_id', '=', $user->getId());
$select->orWhere('payee_user_id', '=', null);
return $select->count() > 0;
}
public function isAnyCommon(): bool
{
$select = new Select(Container::$dbConnection, Transaction::getTable());
$select->where('payee_user_id', '=', null);
return $select->count() > 0;
}
public function isAnyForCurrency(Currency $currency): bool
{
$select = new Select(Container::$dbConnection, Transaction::getTable());
$select->where('currency_id', '=', $currency->getId());
return $select->count() > 0;
}
public function getPagedByCommunity(Community $community, int $page, int $itemsPerPage, bool $useRelations = false, array $withRelations = []): Generator
{
$select = $this->selectAllByCommunity($community);
$select->orderBy('time', 'DESC');
$select->paginate($page, $itemsPerPage);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Transaction::class, $useRelations, $withRelations);
}
public function getPagedByEvent(Event $event, int $page, int $itemsPerPage, bool $useRelations = false, array $withRelations = []): Generator
{
$select = $this->selectAllByEvent($event);
$select->orderBy('time', 'DESC');
$select->paginate($page, $itemsPerPage);
yield from Container::$persistentDataManager->selectMultipleFromDb($select, Transaction::class, $useRelations, $withRelations);
}
private function selectAllByCommunity(Community $community)
{
$select = new Select(Container::$dbConnection, Transaction::getTable());
$select->where('community_id', '=', $community->getId());
return $select;
}
private function selectAllByEvent(Event $event)
{
$select = new Select(Container::$dbConnection, Transaction::getTable());
$select->where('event_id', '=', $event->getId());
return $select;
}
}

View File

@ -5,20 +5,12 @@ use Generator;
use SokoWeb\Database\Query\Select; use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use RVR\PersistentData\Model\UserPasswordResetter; use RVR\PersistentData\Model\UserPasswordResetter;
use SokoWeb\PersistentData\PersistentDataManager;
class UserPasswordResetterRepository class UserPasswordResetterRepository
{ {
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $userConfirmationId): ?UserPasswordResetter public function getById(int $userConfirmationId): ?UserPasswordResetter
{ {
return $this->pdm->selectFromDbById($userConfirmationId, UserPasswordResetter::class); return \Container::$persistentDataManager->selectFromDbById($userConfirmationId, UserPasswordResetter::class);
} }
public function getByToken(string $token): ?UserPasswordResetter public function getByToken(string $token): ?UserPasswordResetter
@ -26,7 +18,7 @@ class UserPasswordResetterRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('token', '=', $token); $select->where('token', '=', $token);
return $this->pdm->selectFromDb($select, UserPasswordResetter::class); return \Container::$persistentDataManager->selectFromDb($select, UserPasswordResetter::class);
} }
public function getByUser(User $user): ?UserPasswordResetter public function getByUser(User $user): ?UserPasswordResetter
@ -34,7 +26,7 @@ class UserPasswordResetterRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('user_id', '=', $user->getId()); $select->where('user_id', '=', $user->getId());
return $this->pdm->selectFromDb($select, UserPasswordResetter::class); return \Container::$persistentDataManager->selectFromDb($select, UserPasswordResetter::class);
} }
public function getAllExpired(): Generator public function getAllExpired(): Generator
@ -42,6 +34,6 @@ class UserPasswordResetterRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s')); $select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s'));
yield from $this->pdm->selectMultipleFromDb($select, UserPasswordResetter::class); yield from \Container::$persistentDataManager->selectMultipleFromDb($select, UserPasswordResetter::class);
} }
} }

View File

@ -4,20 +4,12 @@ use Generator;
use SokoWeb\Interfaces\Repository\IUserRepository; use SokoWeb\Interfaces\Repository\IUserRepository;
use SokoWeb\Database\Query\Select; use SokoWeb\Database\Query\Select;
use RVR\PersistentData\Model\User; use RVR\PersistentData\Model\User;
use SokoWeb\PersistentData\PersistentDataManager;
class UserRepository implements IUserRepository class UserRepository implements IUserRepository
{ {
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $userId): ?User public function getById(int $userId): ?User
{ {
return $this->pdm->selectFromDbById($userId, User::class); return \Container::$persistentDataManager->selectFromDbById($userId, User::class);
} }
public function getByEmail(string $email): ?User public function getByEmail(string $email): ?User
@ -25,7 +17,7 @@ class UserRepository implements IUserRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('email', '=', $email); $select->where('email', '=', $email);
return $this->pdm->selectFromDb($select, User::class); return \Container::$persistentDataManager->selectFromDb($select, User::class);
} }
public function getByUsername(string $username): ?User public function getByUsername(string $username): ?User
@ -33,7 +25,7 @@ class UserRepository implements IUserRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('username', '=', $username); $select->where('username', '=', $username);
return $this->pdm->selectFromDb($select, User::class); return \Container::$persistentDataManager->selectFromDb($select, User::class);
} }
public function getByEmailOrUsername(string $emailOrUsername): ?User public function getByEmailOrUsername(string $emailOrUsername): ?User
@ -50,7 +42,7 @@ class UserRepository implements IUserRepository
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->where('google_sub', '=', $sub); $select->where('google_sub', '=', $sub);
return $this->pdm->selectFromDb($select, User::class); return \Container::$persistentDataManager->selectFromDb($select, User::class);
} }
public function searchByName(string $name): Generator public function searchByName(string $name): Generator
@ -60,6 +52,6 @@ class UserRepository implements IUserRepository
$select->orWhere('nickname', 'LIKE', '%' . $name . '%'); $select->orWhere('nickname', 'LIKE', '%' . $name . '%');
$select->limit(10); $select->limit(10);
yield from $this->pdm->selectMultipleFromDb($select, User::class); yield from \Container::$persistentDataManager->selectMultipleFromDb($select, User::class);
} }
} }

View File

@ -4,37 +4,57 @@
@section(main) @section(main)
<h2>Account</h2> <h2>Account</h2>
<div class="box"> <div class="box compactBox">
<form id="accountForm" action="/<?= Container::$routeCollection->getRoute('account-action')->generateLink() ?>" method="post" data-reload-on-success="true" data-observe-inputs="email,username,password_new,password_new_confirm,nickname,phone,id_number"> <form id="accountForm" action="<?= Container::$routeCollection->getRoute('account-action')->generateLink() ?>" method="post" data-reload-on-success="true" data-observe-inputs="email,username,password_new,password_new_confirm,nickname,phone,id_number">
<?php if ($user['password'] !== null && $user['google_sub'] !== null): ?> <?php if ($user['password'] !== null && $user['google_sub'] !== null): ?>
<p class="justify small">Please confirm your identity with your password or with Google to modify your account.</p> <p class="justify small">Please confirm your identity with your password or with Google to modify your account.</p>
<div class="inputWithButton"> <div class="inputWithButton">
<input type="password" class="text" name="password" placeholder="Current password" required minlength="6" autofocus><!-- <input type="password" class="text" name="password" autocomplete="current-password" required minlength="6" autofocus><!--
--><button id="authenticateWithGoogleButton" class="yellow" type="button">Google</button> --><button id="authenticateWithGoogleButton" class="yellow" type="button"><i class="fa-brands fa-google"></i></button>
</div> </div>
<?php elseif ($user['password'] !== null): ?> <?php elseif ($user['password'] !== null): ?>
<p class="justify small">Please confirm your identity with your password to modify your account.</p> <p class="justify small">Please confirm your identity with your password to modify your account.</p>
<input type="password" class="text big fullWidth" name="password" placeholder="Current password" required minlength="6" autofocus> <input type="password" class="text big fullWidth" name="password" autocomplete="current-password" required minlength="6" autofocus>
<?php elseif ($user['google_sub'] !== null): ?> <?php elseif ($user['google_sub'] !== null): ?>
<p class="justify small">Please confirm your identity with Google to modify your account.</p> <p class="justify small">Please confirm your identity with Google to modify your account.</p>
<div class="inputWithButton"> <div class="inputWithButton">
<input type="text" class="text" name="password" placeholder="Authenticate with Google..." disabled><!-- <input type="text" class="text" name="password" placeholder="Authenticate with Google..." disabled><!--
--><button id="authenticateWithGoogleButton" class="yellow" type="button">Google</button> --><button id="authenticateWithGoogleButton" class="yellow" type="button"><i class="fa-brands fa-google"></i></button>
</div> </div>
<?php endif; ?> <?php endif; ?>
<hr> <hr>
<input type="email" class="text big fullWidth" name="email" placeholder="Email address" value="<?= $user['email'] ?>"> <p class="formLabel">Email address</p>
<input type="text" class="text big fullWidth marginTop" name="username" placeholder="Username" value="<?= $user['username'] ?>"> <input type="email" class="text big fullWidth" name="email" autocomplete="username" value="<?= $user['email'] ?>">
<input type="password" class="text big fullWidth marginTop" name="password_new" placeholder="New password" minlength="6"> <p class="formLabel marginTop">Username</p>
<input type="password" class="text big fullWidth marginTop" name="password_new_confirm" placeholder="New password confirmation" minlength="6"> <input type="text" class="text big fullWidth" name="username" value="<?= $user['username'] ?>">
<p class="formLabel marginTop">New password</p>
<input type="password" class="text big fullWidth" name="password_new" autocomplete="new-password" minlength="6">
<p class="formLabel marginTop">New password confirmation</p>
<input type="password" class="text big fullWidth" name="password_new_confirm" autocomplete="new-password" minlength="6">
<hr> <hr>
<input type="text" class="text big fullWidth marginTop" name="full_name" placeholder="Full name" value="<?= $user['full_name'] ?>" disabled> <p class="formLabel marginTop">Full name</p>
<input type="text" class="text big fullWidth marginTop" name="nickname" placeholder="Nickname" value="<?= $user['nickname'] ?>"> <input type="text" class="text big fullWidth" name="full_name" value="<?= $user['full_name'] ?>" disabled>
<input type="text" class="text big fullWidth marginTop" name="phone" placeholder="Phone" value="<?= $user['phone'] ?>"> <p class="formLabel marginTop marginTop">Nickname</p>
<input type="text" class="text big fullWidth marginTop" name="id_number" placeholder="ID number" value="<?= $user['id_number'] ?>"> <input type="text" class="text big fullWidth" name="nickname" value="<?= $user['nickname'] ?>">
<p class="formLabel marginTop marginTop">Phone</p>
<input type="text" class="text big fullWidth" name="phone" value="<?= $user['phone'] ?>">
<p class="formLabel marginTop marginTop">ID number</p>
<input type="text" class="text big fullWidth" name="id_number" value="<?= $user['id_number'] ?>">
<p id="accountFormError" class="formError justify marginTop"></p> <p id="accountFormError" class="formError justify marginTop"></p>
<div class="right marginTop"> <div class="right marginTop">
<button type="submit" name="submit" disabled>Save</button> <button type="submit" name="submit_button" disabled><i class="fa-regular fa-floppy-disk"></i> Save</button>
</div>
<hr>
<div class="center">
<?php if ($user['google_sub'] === null): ?>
<a class="button yellow" href="<?= Container::$routeCollection->getRoute('account.googleConnect')->generateLink() ?>" title="Connect with Google"><i class="fa-solid fa-link"></i> Connect with Google</a>
<?php else: ?>
<?php if ($user['password'] !== null): ?>
<a class="button yellow" href="<?= Container::$routeCollection->getRoute('account.googleDisconnect')->generateLink() ?>" title="Disconnect from Google"><i class="fa-solid fa-link-slash"></i> Disconnect from Google</a>
<?php else: ?>
<p class="bold small">Your account does not have a password. Please set a password if you want to disconnect your account from Google.</p>
<?php endif; ?>
<?php endif; ?>
</div> </div>
</form> </form>
</div> </div>
@ -42,6 +62,6 @@
@section(pageScript) @section(pageScript)
<script> <script>
var googleAuthenticateUrl = '/<?= Container::$routeCollection->getRoute('account.googleAuthenticate')->generateLink() ?>'; var googleAuthenticateUrl = '<?= Container::$routeCollection->getRoute('account.googleAuthenticate')->generateLink() ?>';
</script> </script>
@endsection @endsection

View File

@ -5,7 +5,7 @@
@section(main) @section(main)
<h2>Authenticate with Google</h2> <h2>Authenticate with Google</h2>
<?php if (!$success): ?> <?php if (!$success): ?>
<div class="box"> <div class="box compactBox">
<p class="error justify"> <p class="error justify">
<?php if (isset($errorText)): ?> <?php if (isset($errorText)): ?>
<?= $errorText ?> <?= $errorText ?>

View File

@ -0,0 +1,22 @@
@extends(templates/layout_normal)
@section(main)
<h2>Connect with Google</h2>
<div class="box compactBox">
<?php if (!$success): ?>
<p class="error justify"><?= $error ?></p>
<?php else: ?>
<form id="connectGoogleForm" action="<?= Container::$routeCollection->getRoute('account.googleConnect-action')->generateLink() ?>" method="post" data-redirect-on-success="<?= Container::$routeCollection->getRoute('account')->generateLink() ?>">
<p class="justify marginBottom">Your account will be connected with the following Google account: <b><?= $googleAccount ?></b></p>
<input type="email" style="display: none;" name="email" autocomplete="username" value="<?= $userEmail ?>">
<p class="formLabel marginTop">Password</p>
<input type="password" class="text big fullWidth" name="password" autocomplete="current-password" required minlength="6" autofocus>
<p class="formError justify marginTop"></p>
<div class="right marginTop">
<button class="marginRight" type="submit" name="submit"><i class="fa-solid fa-link"></i> Connect</button><!--
--><a class="button gray" href="<?= Container::$routeCollection->getRoute('account')->generateLink() ?>" title="Back to account">Cancel</a>
</div>
</form>
<?php endif; ?>
</div>
@endsection

View File

@ -0,0 +1,18 @@
@extends(templates/layout_normal)
@section(main)
<h2>Disconnect from Google</h2>
<div class="box compactBox">
<form id="connectGoogleForm" action="<?= Container::$routeCollection->getRoute('account.googleDisconnect-action')->generateLink() ?>" method="post" data-redirect-on-success="<?= Container::$routeCollection->getRoute('account')->generateLink() ?>">
<p class="justify marginBottom">Your account will be disconnected from the currently set Google account.</p>
<input type="email" style="display: none;" name="email" autocomplete="username" value="<?= $userEmail ?>">
<p class="formLabel marginTop">Password</p>
<input type="password" class="text big fullWidth" name="password" autocomplete="current-password" required minlength="6" autofocus>
<p class="formError justify marginTop"></p>
<div class="right marginTop">
<button class="red marginRight" type="submit" name="submit"><i class="fa-solid fa-link-slash"></i> Disconnect</button><!--
--><a class="button gray" href="<?= Container::$routeCollection->getRoute('account')->generateLink() ?>" title="Back to account">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -1,48 +1,61 @@
@extends(templates/layout_normal) @extends(templates/layout_normal)
@section(main) @section(main)
<h2><?= $community->getName() ?> <span class="small">[<a href="/<?= Container::$routeCollection->getRoute('community-edit')->generateLink(['communityId' => $community->getId()]) ?>">edit</a>]</span></h2> <h2>
<?= $community->getName() ?>
<?php if ($editPermission): ?>
<span class="small">[<a href="<?= Container::$routeCollection->getRoute('community.edit')->generateLink(['communitySlug' => $community->getSlug()]) ?>">edit</a>]</span>
<?php endif; ?>
</h2>
<p class="marginTop"><a href="<?= Container::$routeCollection->getRoute('community.settings')->generateLink(['communitySlug' => $community->getSlug()]) ?>">Settings</a></p>
<div class="gridContainer marginTop"> <div class="gridContainer marginTop">
<div> <div>
<h3 class="marginBottom">Members</h3> <h3 class="marginBottom">Upcoming and recent events</h3>
<?php foreach ($members as $member): ?> <?php if (count($upcomingAndRecentEvents) > 0): ?>
<p><?= $member->getUser()->getDisplayName() ?></p> <?php foreach ($upcomingAndRecentEvents as $event): ?>
<?php endforeach; ?> <p>
<?php if ($editPermission): ?> <a href="<?= Container::$routeCollection->getRoute('community.event')->generateLink(['communitySlug' => $community->getSlug(), 'eventSlug' => $event->getSlug()]) ?>"><?= $event->getTitle() ?></a>
<hr> <span class="small"><?= $event->getStartDate()->format('Y-m-d') ?> <?= $event->getEndDate()->format('Y-m-d') ?></span>
<p><a href="/<?= Container::$routeCollection->getRoute('community-members')->generateLink(['communityId' => $community->getId()]) ?>">Edit members</a></p> </p>
<?php endif; ?>
</div>
<div>
<h3 class="marginBottom">Currencies</h3>
<p>Main currency: <b><?= $community->getCurrency() ?></b></p>
<p>Further currencies: <b><?= implode(', ', $currencyNames) ?></b></p>
</div>
<div>
<h3 class="marginBottom">Upcoming events</h3>
<?php if (count($upcomingEvents) > 0): ?>
<?php foreach ($upcomingEvents as $event): ?>
<!-- todo -->
<?php endforeach; ?> <?php endforeach; ?>
<?php else: ?> <?php else: ?>
<p>There is no upcoming event.</p> <p>There is no event to show.</p>
<?php endif; ?> <?php endif; ?>
<p class="marginTop"><a href="<?= Container::$routeCollection->getRoute('community.events')->generateLink(['communitySlug' => $community->getSlug()]) ?>">All events</a> | <a href="<?= Container::$routeCollection->getRoute('community.events.new')->generateLink(['communitySlug' => $community->getSlug()]) ?>">New event</a></p>
</div> </div>
<div> <div>
<?php
$mainCurrencyCode = $community->getMainCurrency()->getCode();
$mainCurrencyRoundDigits = $community->getMainCurrency()->getRoundDigits();
?>
<h3 class="marginBottom">Finances</h3> <h3 class="marginBottom">Finances</h3>
<table class="fullWidth"> <p><a href="<?= Container::$routeCollection->getRoute('community.transactions')->generateLink(['communitySlug' => $community->getSlug()]) ?>">Transactions</a> | <a href="<?= Container::$routeCollection->getRoute('community.transactions.new')->generateLink(['communitySlug' => $community->getSlug()]) ?>">New transaction</a></p>
<table class="fullWidth marginTop">
<tr> <tr>
<td>You owe</td> <td class="bold">You owe</td>
<td style="text-align: right; color: red;">0 <?= $community->getCurrency() ?></td> <td class="mono red" style="text-align: right;"><?= number_format($debtBalance, $mainCurrencyRoundDigits) ?> <?= $mainCurrencyCode ?></td>
</tr> </tr>
<?php foreach ($debtItems as $item): ?>
<tr>
<td class="small"><?= $item['payee']->getUser()->getDisplayName() ?></td>
<td class="small mono red" style="text-align: right;"><?= number_format($item['amount'], $mainCurrencyRoundDigits) ?> <?= $mainCurrencyCode ?></td>
</tr>
<?php endforeach; ?>
<tr> <tr>
<td>You're owed</td> <td class="bold">You're owed</td>
<td style="text-align: right; color: green;">0 <?= $community->getCurrency() ?></td> <td class="mono green" style="text-align: right;"><?= number_format($outstandingBalance, $mainCurrencyRoundDigits) ?> <?= $mainCurrencyCode ?></td>
</tr> </tr>
<?php foreach ($outstandingItems as $item): ?>
<tr>
<td class="small"><?= $item['payer']->getUser()->getDisplayName() ?></td>
<td class="small mono green" style="text-align: right;"><?= number_format($item['amount'], $mainCurrencyRoundDigits) ?> <?= $mainCurrencyCode ?></td>
</tr>
<?php endforeach; ?>
<tr> <tr>
<td>Your balance</td> <td class="bold">Your balance</td>
<td style="text-align: right;">0 <?= $community->getCurrency() ?></td> <td class="mono <?= $balance < 0 ? 'red' : ($balance > 0 ? 'green' : '') ?>" style="text-align: right;;"><?= number_format($balance, $mainCurrencyRoundDigits) ?> <?= $mainCurrencyCode ?></td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -0,0 +1,51 @@
@extends(templates/layout_normal)
@section(main)
<h2>
<a href="<?= Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()]) ?>"><?= $community->getName() ?></a> »
<a href="<?= Container::$routeCollection->getRoute('community.settings')->generateLink(['communitySlug' => $community->getSlug()]) ?>">Settings</a> »
Edit currencies
</h2>
<div class="box compactBox">
<table class="fullWidth">
<thead>
<tr>
<th style="width: calc(100% / 3); text-align: left;">Code</th>
<th style="width: calc(100% / 3); text-align: left;">Round digits</th>
<th style="width: calc(100% / 3);"></th>
</tr>
</thead>
<?php foreach ($currencies as $currency): ?>
<tr>
<td>
<form id="editCurrency_<?= $currency->getId() ?>" action="<?= Container::$routeCollection->getRoute('community.currencies.edit-action')->generateLink(['communitySlug' => $community->getSlug(), 'currency_id' => $currency->getId()]) ?>" method="post" data-reload-on-success="true" data-observe-inputs="code,round_digits"></form>
<form id="deleteCurrency_<?= $currency->getId() ?>" action="<?= Container::$routeCollection->getRoute('community.currencies.delete-action')->generateLink(['communitySlug' => $community->getSlug(), 'currency_id' => $currency->getId()]) ?>" method="post" data-reload-on-success="true"></form>
<input type="text" form="editCurrency_<?= $currency->getId() ?>" class="text fullWidth" name="code" value="<?= $currency->getCode() ?>" maxlength="3" required>
</td>
<td>
<input type="number" form="editCurrency_<?= $currency->getId() ?>" class="text fullWidth" name="round_digits" value="<?= $currency->getRoundDigits() ?>" min="0" max="9" required>
</td>
<td style="text-align: right; font-size: 0;">
<button type="submit" form="editCurrency_<?= $currency->getId() ?>" name="submit_button" class="small" disabled><i class="fa-regular fa-floppy-disk"></i></button>
<?php if ($currency->getId() !== $community->getMainCurrencyId()): ?>
<button type="submit" form="deleteCurrency_<?= $currency->getId() ?>" name="submit_button" data-confirmation="Are you sure you want to delete this currency?" data-confirmation-button='<i class="fa-regular fa-trash-can"></i> Delete' class="small red marginLeft"><i class="fa-regular fa-trash-can"></i></button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td>
<form id="newCurrency" action="<?= Container::$routeCollection->getRoute('community.currencies.new-action')->generateLink(['communitySlug' => $community->getSlug()]) ?>" method="post" data-reload-on-success="true" data-observe-inputs="code,round_digits"></form>
<input type="text" form="newCurrency" class="text fullWidth" name="code" maxlength="3" required>
</td>
<td>
<input type="number" form="newCurrency" class="text fullWidth" name="round_digits" min="0" max="9" required>
</td>
<td style="text-align: right;">
<button type="submit" form="newCurrency" name="submit_button" class="small" disabled><i class="fa-regular fa-plus"></i></button>
</td>
</tr>
</table>
<p class="formError justify marginTop"></p>
</div>
@endsection

View File

@ -1,20 +1,39 @@
@extends(templates/layout_normal) @extends(templates/layout_normal)
@section(main) @section(main)
<h2><?= isset($community) ? $community->getName() . ' - Edit' : 'New community' ?></h2> <h2>
<div class="box"> <?php if (isset($community)): ?>
<a href="<?= Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()]) ?>"><?= $community->getName() ?></a> »
Edit
<?php else: ?>
New community
<?php endif; ?>
</h2>
<div class="box compactBox">
<?php <?php
$formAction = isset($community) ? $formAction = isset($community) ?
Container::$routeCollection->getRoute('community-edit-action')->generateLink(['communityId' => $community->getId()]) : Container::$routeCollection->getRoute('community.edit-action')->generateLink(['communitySlug' => $community->getSlug()]) :
Container::$routeCollection->getRoute('community-new-action')->generateLink(); Container::$routeCollection->getRoute('community.new-action')->generateLink();
?> ?>
<form id="communityForm" action="/<?= $formAction ?>" method="post" data-redirect-on-success="true"> <form id="communityForm" action="<?= $formAction ?>" method="post" data-redirect-on-success="true">
<input type="text" class="text big fullWidth" name="name" placeholder="Name" value="<?= isset($community) ? $community->getName() : '' ?>" required> <p class="formLabel">Name</p>
<input type="text" class="text big fullWidth marginTop" name="currency" value="<?= isset($community) ? $community->getCurrency() : '' ?>" placeholder="Default currency" maxlength="3" required> <input type="text" class="text big fullWidth" name="name" value="<?= isset($community) ? $community->getName() : '' ?>" required>
<p id="accountFormError" class="formError justify marginTop"></p> <?php if (!isset($community)): ?>
<div class="right marginTop"> <p class="formLabel marginTop">Main currency code</p>
<button type="submit" name="submit"><?= isset($community) ? 'Save' : 'Create' ?></button> <input type="text" class="text big fullWidth" name="main_currency_code" maxlength="3" required>
<p class="formLabel marginTop">Main currency round digits</p>
<input type="number" class="text big fullWidth" name="main_currency_round_digits" min="0" max="9" required>
<?php endif; ?>
<p id="communityFormError" class="formError justify marginTop"></p>
<div class="right marginTop" style="font-size: 0;">
<button type="submit" name="submit_button"><?= isset($community) ? '<i class="fa-regular fa-floppy-disk"></i> Save' : '<i class="fa-regular fa-plus"></i> Create' ?></button>
<?php if (isset($community)): ?>
<button type="submit" form="deleteCommunity" name="submit_button" data-confirmation="Are you sure you want to delete this community?" class="red marginLeft"><i class="fa-regular fa-trash-can"></i> Delete</button>
<?php endif; ?>
</div> </div>
</form> </form>
<?php if (isset($community)): ?>
<form id="deleteCommunity" action="<?= Container::$routeCollection->getRoute('community.delete-action')->generateLink(['communitySlug' => $community->getSlug()]) ?>" method="post" data-redirect-on-success="<?= Container::$routeCollection->getRoute('home')->generateLink() ?>"></form>
<?php endif; ?>
</div> </div>
@endsection @endsection

View File

@ -1,16 +1,20 @@
@css(node_modules/choices.js/public/assets/styles/choices.min.css) @css(node_modules/tom-select/dist/css/tom-select.min.css)
@js(node_modules/choices.js/public/assets/scripts/choices.js) @js(node_modules/tom-select/dist/js/tom-select.base.min.js)
@js(js/communities/community_members.js) @js(js/communities/community_members.js)
@extends(templates/layout_normal) @extends(templates/layout_normal)
@section(main) @section(main)
<h2><?= $community->getName() ?> - Edit members</h2> <h2>
<div class="box"> <a href="<?= Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()]) ?>"><?= $community->getName() ?></a> »
<a href="<?= Container::$routeCollection->getRoute('community.settings')->generateLink(['communitySlug' => $community->getSlug()]) ?>">Settings</a> »
Edit members
</h2>
<div class="box compactBox">
<table class="fullWidth"> <table class="fullWidth">
<thead> <thead>
<tr> <tr>
<th style="width: 50%;"></th> <th style="width: 50%;">User</th>
<th style="width: 25%; text-align: center;">Owner</th> <th style="width: 25%; text-align: center;">Owner</th>
<th style="width: 25%;"></th> <th style="width: 25%;"></th>
</tr> </tr>
@ -18,25 +22,41 @@
<?php foreach ($members as $member): ?> <?php foreach ($members as $member): ?>
<?php $editable = $member->getUserId() !== Container::$request->user()->getUniqueId(); ?> <?php $editable = $member->getUserId() !== Container::$request->user()->getUniqueId(); ?>
<tr> <tr>
<td><?= $member->getUser()->getDisplayName() ?></td> <td>
<td style="text-align: center;"><input type="checkbox" class="member_owner" data-id="<?= $member->getId() ?>" <?= $member->getOwner() ? 'checked' : '' ?> <?= !$editable ? 'disabled' : '' ?> /></td> <form id="editMember_<?= $member->getId() ?>" action="<?= Container::$routeCollection->getRoute('community.members.edit-action')->generateLink(['communitySlug' => $community->getSlug(), 'community_member_id' => $member->getId()]) ?>" method="post" data-reload-on-success="true" data-observe-inputs="owner"></form>
<td style="text-align: right;"><button type="button" class="small red delete_member" data-id="<?= $member->getId() ?>" <?= !$editable ? 'disabled' : '' ?>>Delete</button></td> <form id="deleteMember_<?= $member->getId() ?>" action="<?= Container::$routeCollection->getRoute('community.members.delete-action')->generateLink(['communitySlug' => $community->getSlug(), 'community_member_id' => $member->getId()]) ?>" method="post" data-reload-on-success="true"></form>
<?= $member->getUser()->getDisplayName() ?>
</td>
<td style="text-align: center;">
<input type="checkbox" form="editMember_<?= $member->getId() ?>" name="owner" <?= $member->getOwner() ? 'checked' : '' ?> <?= !$editable ? 'disabled' : '' ?> />
</td>
<td style="text-align: right;">
<?php if ($editable): ?>
<button type="submit" form="editMember_<?= $member->getId() ?>" name="submit_button" class="small marginRight" disabled><i class="fa-regular fa-floppy-disk"></i></button><!--
--><button type="submit" form="deleteMember_<?= $member->getId() ?>" name="submit_button" data-confirmation="Are you sure you want to delete this member?" data-confirmation-button='<i class="fa-regular fa-trash-can"></i> Delete' class="small red"><i class="fa-regular fa-trash-can"></i></button>
<?php endif; ?>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<tr> <tr>
<td><select type="text" id="new_member_user_id"></td> <td>
<td></td> <form id="newMember" action="<?= Container::$routeCollection->getRoute('community.members.new-action')->generateLink(['communitySlug' => $community->getSlug()]) ?>" method="post" data-reload-on-success="true" data-observe-inputs="user_id"></form>
<td style="text-align: right;"><button type="button" class="small" id="new_member_button" disabled>Add</button></td> <select form="newMember" name="user_id" required></select>
</td>
<td style="text-align: center;">
<input type="checkbox" form="newMember" name="owner" />
</td>
<td style="text-align: right;">
<button type="submit" form="newMember" name="submit_button" class="small" disabled><i class="fa-regular fa-plus"></i></button>
</td>
</tr> </tr>
</table> </table>
<p class="formError justify marginTop"></p>
</div> </div>
@endsection @endsection
@section(pageScript) @section(pageScript)
<script> <script>
var searchUserUrl = '/<?= Container::$routeCollection->getRoute('searchUser')->generateLink(['q' => 'QUERY']) ?>'; var searchUserUrl = '<?= Container::$routeCollection->getRoute('searchUser')->generateLink(['q' => 'QUERY']) ?>';
var newMemberUrl = '/<?= Container::$routeCollection->getRoute('community-members-new')->generateLink(['communityId' => $community->getId()]) ?>';
var editMemberUrl = '/<?= Container::$routeCollection->getRoute('community-members-edit')->generateLink(['communityId' => $community->getId()]) ?>';
var deleteMemberUrl = '/<?= Container::$routeCollection->getRoute('community-members-delete')->generateLink(['communityId' => $community->getId()]) ?>';
</script> </script>
@endsection @endsection

View File

@ -0,0 +1,39 @@
@extends(templates/layout_normal)
@section(main)
<h2>
<a href="<?= Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()]) ?>"><?= $community->getName() ?></a> »
Settings
</h2>
<div class="gridContainer marginTop">
<div>
<h3 class="marginBottom">
Members
<?php if ($editPermission): ?>
<span class="small">[<a href="<?= Container::$routeCollection->getRoute('community.members')->generateLink(['communitySlug' => $community->getSlug()]) ?>">edit</a>]</span>
<?php endif; ?>
</h3>
<?php foreach ($members as $member): ?>
<p><?= $member->getUser()->getDisplayName() ?></p>
<?php endforeach; ?>
</div>
<div>
<h3 class="marginBottom">
Currencies
<?php if ($editPermission): ?>
<span class="small">[<a href="<?= Container::$routeCollection->getRoute('community.currencies')->generateLink(['communitySlug' => $community->getSlug()]) ?>">edit</a>]</span>
<?php endif; ?>
</h3>
<?php foreach ($currencies as $currency): ?>
<p>
<?php if ($currency->getId() === $community->getMainCurrencyId()): ?>
<b><?= $currency->getCode() ?></b>
<?php else: ?>
<a href="<?= Container::$routeCollection->getRoute('community.currencyExchangeRates')->generateLink(['communitySlug' => $community->getSlug(), 'code' => $currency->getCode()]) ?>"><?= $currency->getCode() ?></a>
<?php endif; ?>
</p>
<?php endforeach; ?>
</div>
</div>
@endsection

View File

@ -0,0 +1,61 @@
@extends(templates/layout_normal)
@section(main)
<h2>
<a href="<?= Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()]) ?>"><?= $community->getName() ?></a> »
<a href="<?= Container::$routeCollection->getRoute('community.settings')->generateLink(['communitySlug' => $community->getSlug()]) ?>">Settings</a> »
Exchange rates for <?= $currency->getCode() ?>
</h2>
<div class="box compactBox">
<table class="fullWidth">
<thead>
<tr>
<?php if ($editPermission): ?>
<th style="width: 25%; text-align: left;">Exchange rate</th>
<th style="width: 50%; text-align: left;">Valid from</th>
<th style="width: 25%;"></th>
<?php else: ?>
<th style="width: 35%; text-align: left;">Exchange rate</th>
<th style="width: 65%; text-align: left;">Valid from</th>
<?php endif; ?>
</tr>
</thead>
<?php foreach ($currencyExchangeRates as $currencyExchangeRate): ?>
<tr>
<?php if ($editPermission): ?>
<td>
<form id="editExchangeRate_<?= $currencyExchangeRate->getId() ?>" action="<?= Container::$routeCollection->getRoute('community.currencyExchangeRates.edit-action')->generateLink(['communitySlug' => $community->getSlug(), 'code' => $currency->getCode(), 'currency_exchange_rate_id' => $currencyExchangeRate->getId()]) ?>" method="post" data-reload-on-success="true" data-observe-inputs="exchange_rate,valid_from"></form>
<form id="deleteCurrency_<?= $currencyExchangeRate->getId() ?>" action="<?= Container::$routeCollection->getRoute('community.currencyExchangeRates.delete-action')->generateLink(['communitySlug' => $community->getSlug(), 'code' => $currency->getCode(), 'currency_exchange_rate_id' => $currencyExchangeRate->getId()]) ?>" method="post" data-reload-on-success="true"></form>
<input type="number" form="editExchangeRate_<?= $currencyExchangeRate->getId() ?>" class="text fullWidth" name="exchange_rate" value="<?= $currencyExchangeRate->getExchangeRate() ?>" min="0" step="0.000000001" required>
</td>
<td>
<input type="datetime-local" form="editExchangeRate_<?= $currencyExchangeRate->getId() ?>" class="text fullWidth" name="valid_from" value="<?= $currencyExchangeRate->getValidFromDate()->format('Y-m-d\TH:i') ?>" required>
</td>
<td style="text-align: right;">
<button type="submit" form="editExchangeRate_<?= $currencyExchangeRate->getId() ?>" name="submit_button" class="small marginRight" disabled><i class="fa-regular fa-floppy-disk"></i></button><!--
--><button type="submit" form="deleteCurrency_<?= $currencyExchangeRate->getId() ?>" name="submit_button" data-confirmation="Are you sure you want to delete this exchange rate?" data-confirmation-button='<i class="fa-regular fa-trash-can"></i> Delete' class="small red"><i class="fa-regular fa-trash-can"></i></button>
</td>
<?php else: ?>
<td><?= $currencyExchangeRate->getExchangeRate() ?></td>
<td><?= $currencyExchangeRate->getValidFromDate()->format('Y-m-d H:i') ?></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php if ($editPermission): ?>
<tr>
<td>
<form id="newExchangeRate" action="<?= Container::$routeCollection->getRoute('community.currencyExchangeRates.new-action')->generateLink(['communitySlug' => $community->getSlug(), 'code' => $currency->getCode()]) ?>" method="post" data-reload-on-success="true" data-observe-inputs="exchange_rate,valid_from"></form>
<input type="number" form="newExchangeRate" class="text fullWidth" name="exchange_rate" min="0" step="0.000000001" required>
</td>
<td>
<input type="datetime-local" form="newExchangeRate" class="text fullWidth" name="valid_from" required>
</td>
<td style="text-align: right;">
<button type="submit" form="newExchangeRate" name="submit_button" class="small" disabled><i class="fa-regular fa-plus"></i></button>
</td>
</tr>
<?php endif; ?>
</table>
<p class="formError justify marginTop"></p>
</div>
@endsection

View File

@ -0,0 +1,87 @@
@css(node_modules/tom-select/dist/css/tom-select.min.css)
@js(node_modules/tom-select/dist/js/tom-select.base.min.js)
@js(js/communities/transaction.js)
@extends(templates/layout_normal)
@section(main)
<h2>
<a href="<?= Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()]) ?>"><?= $community->getName() ?></a> »
<?php if (isset($event)): ?>
<a href="<?= Container::$routeCollection->getRoute('community.events')->generateLink(['communitySlug' => $community->getSlug()]) ?>">Events</a> »
<a href="<?= Container::$routeCollection->getRoute('community.event')->generateLink(['communitySlug' => $community->getSlug(), 'eventSlug' => $event->getSlug()]) ?>"><?= $event->getTitle() ?></a> »
<?php endif; ?>
<a href="<?= Container::$routeCollection->getRoute('community.transactions')->generateLink(['communitySlug' => $community->getSlug(), 'event' => isset($event) ? $event->getSlug() : null]) ?>">Transactions</a> »
<?php if (isset($transaction)): ?>
Edit transaction
<?php else: ?>
New transaction
<?php endif; ?>
</h2>
<div class="box compactBox">
<?php
$formAction = isset($transaction) ?
Container::$routeCollection->getRoute('community.transactions.edit-action')->generateLink(['communitySlug' => $community->getSlug(), 'transactionId' => $transaction->getId()]) :
Container::$routeCollection->getRoute('community.transactions.new-action')->generateLink(['communitySlug' => $community->getSlug()]);
?>
<form id="transactionForm" action="<?= $formAction ?>" method="post" data-redirect-on-success="<?= Container::$routeCollection->getRoute('community.transactions')->generateLink(['communitySlug' => $community->getSlug(), 'event' => isset($event) ? $event->getSlug() : null]) ?>">
<p class="formLabel">Event</p>
<select name="event_id">
<option value="">[none]</option>
<?php if (isset($event)): ?>
<option value="<?= $event->getId() ?>" selected><?= $event->getTitle() ?></option>
<?php endif; ?>
</select>
<p class="formLabel marginTop">Payer</p>
<select class="big fullWidth" name="payer_user_id" required>
<option value="" hidden></option>
<?php foreach ($members as $member): ?>
<option value="<?= $member->getUser()->getId() ?>"
<?= isset($transaction) ?
($transaction->getPayerUserId() === $member->getUser()->getId() ? 'selected' : '') :
(Container::$request->user()->getUniqueId() == $member->getUser()->getId() ? 'selected' : '') ?>>
<?= $member->getUser()->getDisplayName() ?>
</option>
<?php endforeach; ?>
</select>
<p class="formLabel marginTop">Payee(s)</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); grid-gap: 10px;">
<?php foreach ($members as $member): ?>
<div style="text-align: center;">
<input id="payee_<?= $member->getUserId() ?>" type="checkbox" name="payee_user_ids[]" value="<?= $member->getUserId() ?>" <?= !isset($transaction) || count($payeeUserIds) === 0 || in_array($member->getUserId(), $payeeUserIds) ? 'checked' : '' ?>><!--
--><label for="payee_<?= $member->getUserId() ?>"><?= $member->getUser()->getDisplayName() ?></label>
</div>
<?php endforeach; ?>
</div>
<p class="formLabel marginTop">Description</p>
<input type="text" class="text big fullWidth" name="description" value="<?= isset($transaction) ? $transaction->getDescription() : '' ?>" required>
<p class="formLabel marginTop">Sum</p>
<input type="number" class="text big fullWidth" name="sum" value="<?= isset($transaction) ? $transaction->getSum() : '' ?>" min="0" step="0.000000001" required>
<p class="formLabel marginTop">Currency</p>
<select class="big fullWidth" name="currency_id" required>
<option value="" hidden></option>
<?php foreach ($currencies as $currency): ?>
<option value="<?= $currency->getId() ?>" <?= isset($transaction) && $transaction->getCurrencyId() === $currency->getId() ? 'selected' : '' ?>><?= $currency->getCode() ?></option>
<?php endforeach; ?>
</select>
<p class="formLabel marginTop">Time</p>
<input type="datetime-local" class="text big fullWidth" name="time" value="<?= isset($transaction) ? $transaction->getTimeDate()->format('Y-m-d\TH:i') : (new DateTime())->format('Y-m-d\TH:i') ?>" required>
<p class="formError justify marginTop"></p>
<div class="right marginTop" style="font-size: 0;">
<button type="submit" name="submit_button"><?= isset($transaction) ? '<i class="fa-regular fa-floppy-disk"></i> Save' : '<i class="fa-regular fa-plus"></i> Create' ?></button>
<?php if (isset($transaction)): ?>
<button type="submit" form="deleteTransaction" name="submit_button" data-confirmation="Are you sure you want to delete this transaction?" class="red marginLeft"><i class="fa-regular fa-trash-can"></i> Delete</button>
<?php endif; ?>
</div>
</form>
<?php if (isset($transaction)): ?>
<form id="deleteTransaction" action="<?= Container::$routeCollection->getRoute('community.transactions.delete-action')->generateLink(['communitySlug' => $community->getSlug(), 'transactionId' => $transaction->getId()]) ?>" method="post" data-redirect-on-success="<?= Container::$routeCollection->getRoute('community.transactions')->generateLink(['communitySlug' => $community->getSlug()]) ?>"></form>
<?php endif; ?>
</div>
@endsection
@section(pageScript)
<script>
var searchEventUrl = '<?= Container::$routeCollection->getRoute('community.events.search')->generateLink(['communitySlug' => $community->getSlug(), 'q' => 'QUERY']) ?>';
</script>
@endsection

View File

@ -0,0 +1,78 @@
@extends(templates/layout_normal)
@section(main)
<h2>
<a href="<?= Container::$routeCollection->getRoute('community')->generateLink(['communitySlug' => $community->getSlug()]) ?>"><?= $community->getName() ?></a> »
<?php if (isset($event)): ?>
<a href="<?= Container::$routeCollection->getRoute('community.events')->generateLink(['communitySlug' => $community->getSlug()]) ?>">Events</a> »
<a href="<?= Container::$routeCollection->getRoute('community.event')->generateLink(['communitySlug' => $community->getSlug(), 'eventSlug' => $event->getSlug()]) ?>"><?= $event->getTitle() ?></a> »
<?php endif; ?>
Transactions
</h2>
<p class="marginTop"><a href="<?= Container::$routeCollection->getRoute('community.transactions.new')->generateLink(['communitySlug' => $community->getSlug(), 'event' => isset($event) ? $event->getSlug() : null]) ?>">New transaction</a></p>
<?php if ($numberOfTransactions > 0): ?>
<?php
$paginationRouteId = 'community.transactions';
$paginationRouteParams = ['communitySlug' => $community->getSlug()];
?>
<?php if ($pages > 1): ?>
@include(templates/pagination)
<?php endif; ?>
<?php foreach ($transactions as $transaction): ?>
<a class="block" href="<?= Container::$routeCollection->getRoute('community.transactions.edit')->generateLink(['communitySlug' => $community->getSlug(), 'transactionId' => $transaction->getId()]) ?>">
<div class="box transaction">
<div>
<?php if ($transaction->getEvent()): ?>
<p><span class="label"><?= $transaction->getEvent()->getTitle() ?></span></p>
<?php endif; ?>
<p style="font-weight: bold;"><?= $transaction->getDescription() ?></p>
<p class="small">
<?= $transaction->getPayerUser()->getDisplayName() ?>
<i class="fa-solid fa-caret-right"></i>
<?php foreach ($members as $member): ?>
<?php
if (count($transaction->getPayees()) > 0) {
$found = false;
foreach ($transaction->getPayees() as $payee) {
if ($member->getUserId() === $payee->getUserId()) {
$found = true;
}
}
} else {
$found = true;
}
?>
<?php if ($found): ?>
<?= $member->getUser()->getDisplayName() ?>
<?php else: ?>
<span class="gray" style="text-decoration: line-through;"><?= $member->getUser()->getDisplayName() ?></span>
<?php endif; ?>
<?php endforeach; ?>
</p>
<p class="small"><?= $transaction->getTimeDate()->format('Y-m-d H:i') ?></p>
</div>
<div style="text-align: right;">
<h3><?= number_format($exchangeRateCalculator->calculate($transaction->getSum(), $transaction->getCurrency(), $transaction->getTimeDate()), $community->getMainCurrency()->getRoundDigits()) ?> <?= $community->getMainCurrency()->getCode() ?></h3>
<?php if ($community->getMainCurrencyId() !== $transaction->getCurrencyId()): ?>
<p style="color: #8e8e8e; font-weight: bold;"><?= number_format($transaction->getSum(), $transaction->getCurrency()->getRoundDigits()) ?> <?= $transaction->getCurrency()->getCode() ?></p>
<?php endif; ?>
</div>
</div>
</a>
<?php endforeach; ?>
<?php if ($pages > 1): ?>
@include(templates/pagination)
<?php endif; ?>
<p class="marginTop"><a href="<?= Container::$routeCollection->getRoute('community.transactions.new')->generateLink(['communitySlug' => $community->getSlug(), 'event' => isset($event) ? $event->getSlug() : null]) ?>">New transaction</a></p>
<?php else: ?>
<div class="box">
<p>There are no transactions yet.</p>
</div>
<?php endif; ?>
@endsection

View File

@ -2,5 +2,5 @@
@section(main) @section(main)
<h2>404 | Page not found</h2> <h2>404 | Page not found</h2>
<p>The requested URL was not found on this server. <a href="/" title="<?= $_ENV['APP_NAME'] ?>">Back to start.</a></p> <p>The requested URL was not found on this server. <a href="<?= Container::$routeCollection->getRoute('home')->generateLink() ?>" title="<?= $_ENV['APP_NAME'] ?>">Back to start.</a></p>
@endsection @endsection

11
views/error/500.php Normal file
View File

@ -0,0 +1,11 @@
@extends(templates/layout_normal)
@section(main)
<h2>500 | Internal server error</h2>
<p>An error occured during processing your request. <a href="<?= Container::$routeCollection->getRoute('home')->generateLink() ?>" title="<?= $_ENV['APP_NAME'] ?>">Back to start.</a></p>
<?php if (isset($exceptionToPrint)): ?>
<pre class="marginTop">
<?= $exceptionToPrint ?>
</pre>
<?php endif; ?>
@endsection

View File

@ -0,0 +1,8 @@
@extends(templates/layout_normal)
@section(main)
<h2>No event found</h2>
<div class="box compactBox">
<p class="error justify">No upcoming or recent event was found. <a href="<?= Container::$routeCollection->getRoute('home')->generateLink() ?>" title="<?= $_ENV['APP_NAME'] ?>">Back to start.</a></p>
</div>
@endsection

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